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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +6 -6
  2. package/dist/config-schema.json +7 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +2 -0
  6. package/dist/index.js.map +1 -1
  7. package/package.json +11 -11
  8. package/src/build/canonical-target.js +10 -2
  9. package/src/build/effect-invocations.js +49 -0
  10. package/src/build/embedded-assets.as.ts +32 -0
  11. package/src/build/kv-native.as.ts +18 -8
  12. package/src/build/native-application-errors.js +49 -26
  13. package/src/build/native-http-effects.js +41 -12
  14. package/src/build/native-platform-capabilities.js +346 -120
  15. package/src/build/native-string-values.js +1 -1
  16. package/src/build/native-value-budget.js +58 -0
  17. package/src/build/package-native-application.js +94 -0
  18. package/src/build/request-budget.js +3 -1
  19. package/src/build/s3-body.as.ts +97 -0
  20. package/src/build/s3-native.as.ts +9 -3
  21. package/src/build/s3-native.js +2 -2
  22. package/src/build/schema-admission.js +83 -0
  23. package/src/build/schema-nested-json.js +93 -0
  24. package/src/build/schema-scalar-record.js +41 -0
  25. package/src/build/wasm-budget.js +28 -0
  26. package/src/config-api.js +2 -0
  27. package/src/config-schema.json +7 -0
  28. package/src/index.ts +5 -0
  29. package/src/javascript/jwt-verifier.js +14 -4
  30. package/src/javascript/package-effects.js +1 -1
  31. package/src/javascript/target-support-policy.js +11 -5
  32. package/src/javascript/target.js +10 -0
  33. package/src/provider-contract.js +4 -2
  34. package/src/testing/native-http-effects-host.js +5 -1
  35. package/src/testing/native-platform-capabilities-host.js +24 -3
  36. package/src/toolchain/config.js +4 -0
  37. package/src/toolchain/index.js +30 -1
  38. package/src/toolchain/native-execution.js +1 -0
  39. package/src/toolchain/s3.js +2 -2
@@ -107,7 +107,7 @@ function needsNativeValueFailureGuard(plan) {
107
107
  if (node.kind === 'pure-loop' || (node.kind === 'method-call' && node.method === 'string.trim')) return true;
108
108
  return Object.values(node).some(containsValueOperation);
109
109
  }
110
- return containsValueOperation(plan.entry?.body) || containsValueOperation(plan.effects);
110
+ return containsValueOperation(plan.entry?.body) || containsValueOperation(plan.handlers) || containsValueOperation(plan.stages) || containsValueOperation(plan.helpers) || containsValueOperation(plan.effects);
111
111
  }
112
112
 
113
113
  module.exports = { nativeStringFields, nativeStringConcat, nativeStringTrim, nativeStringIndex, needsNativeValueFailureGuard };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ const { CANONICAL_NATIVE_READ_LOOP_MEMORY: policy, hasBoundedReadLoop } = require('@pulse-compute/wasm-contracts/handler/canonical-native-runtime');
4
+
5
+ function runtimeSource(limits = policy) {
6
+ return `
7
+ let __pulse_memory_bytes: i64 = 0
8
+ let __pulse_memory_values: i64 = 0
9
+ let __pulse_memory_failed: bool = false
10
+ function __pulse_memory_charge(values: i64, bytes: i64): void {
11
+ if (__pulse_memory_failed) unreachable()
12
+ if (values > ${limits.maxValues} - __pulse_memory_values || bytes > ${limits.maxBytes} - __pulse_memory_bytes) {
13
+ __pulse_memory_failed = true
14
+ __pulse_fastly_fail(1010, values > ${limits.maxValues} - __pulse_memory_values ? 173 : 172, -1)
15
+ __pulse_invocation_close()
16
+ unreachable()
17
+ }
18
+ __pulse_memory_values += values
19
+ __pulse_memory_bytes += bytes
20
+ }
21
+ function __pulse_memory_value_push(target: Array<i32>, value: i32): void {
22
+ __pulse_memory_charge(1, ${limits.edgeBytes})
23
+ target.push(value)
24
+ }
25
+ function __pulse_memory_key_push(target: Array<string>, value: string): void {
26
+ __pulse_memory_charge(1, ${limits.edgeBytes} + i64(value.length) * 2)
27
+ target.push(value)
28
+ }
29
+ function __pulse_memory_header_push(target: Array<__PulseFastlyHeader>, value: __PulseFastlyHeader): void {
30
+ __pulse_memory_charge(1, ${limits.valueBytes} + i64(value.name.length + value.value.length) * 2)
31
+ target.push(value)
32
+ }
33
+ export function pulse_fastly_memory_bytes(): i64 { return __pulse_memory_bytes }
34
+ export function pulse_fastly_memory_values(): i64 { return __pulse_memory_values }
35
+ `;
36
+ }
37
+
38
+ function instrument(source, plan) {
39
+ if (!hasBoundedReadLoop(plan)) return source;
40
+ function insert(needle, extra) {
41
+ if (source.split(needle).length !== 2) throw new Error('Native value budget instrumentation mismatch: ' + needle);
42
+ source = source.replace(needle, needle + extra);
43
+ }
44
+ // Text assignments include lazy fetch bodies and response bodies as well as
45
+ // strings. Ropes reserve their eventual flat size before materialization.
46
+ insert('\n set text(value: string) {', '\n __pulse_memory_charge(0, i64(value.length) * 2)');
47
+ insert('\nfunction __pulse_fastly_put(value: __PulseFastlyValue): i32 {',
48
+ ` __pulse_memory_charge(1, ${policy.valueBytes} + (value.textLeft === null ? 0 : i64(value.textLength) * 2));`);
49
+ const helpers = { keys: 'key', values: 'value', headers: 'header' };
50
+ // Skip quoted data and comments: authored text can contain source-like text.
51
+ const tokens = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\/\/[^\n]*|\/\*[\s\S]*?\*\/)|\b([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\.(keys|values|headers)\.push\(/g;
52
+ source = source.replace(tokens, (match, quoted, object, field) => quoted || `__pulse_memory_${helpers[field]}_push(${object}.${field}, `);
53
+ const code = source.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, '');
54
+ if (/\.(keys|values|headers)\.push\(/.test(code)) throw new Error('Unmetered Native container growth.');
55
+ return source + runtimeSource();
56
+ }
57
+
58
+ module.exports = { policy, hasBoundedReadLoop, runtimeSource, instrument };
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const {
5
+ CANONICAL_NATIVE_IMPORT_NAMES,
6
+ CANONICAL_NATIVE_VALUE_TRANSFER_MAX_BYTES
7
+ } = require('@pulse-compute/wasm-contracts/handler/canonical-native-runtime');
8
+
9
+ function enabled(plan) { return Boolean(plan.packages && plan.packages.application); }
10
+
11
+ // The package owns request dispatch, codecs and continuations. This adapter only
12
+ // binds its canonical host imports to the same Fastly runtime as ordinary handlers.
13
+ function portable(plan) {
14
+ const application = plan.packages.application;
15
+ if (application.contractId !== 'pulse.entities'
16
+ || application.version !== 'pulse.package-native-application.v1'
17
+ || application.effectFailure !== 'package-completion'
18
+ || application.automaticFallback !== false
19
+ || crypto.createHash('sha256').update(application.source).digest('hex') !== application.sourceHash) {
20
+ throw new Error('Unsupported or invalid Fastly package Native application.');
21
+ }
22
+ const source = application.source.replace(
23
+ /^@external\("pulse_host", "([a-z_]+)"\) declare function (\w+)\(([^)]*)\): (i32|void)$/gm,
24
+ (_declaration, name, symbol, parameters, result) => {
25
+ if (!CANONICAL_NATIVE_IMPORT_NAMES.includes(name)) throw new Error('Unsupported package host import: ' + name);
26
+ const args = parameters ? parameters.split(',').map(parameter => parameter.trim().split(':')[0]).join(', ') : '';
27
+ if (name === 'value_json') return `function ${symbol}(${parameters}): i32 {
28
+ // A failed request read must reach the provider's terminal error response,
29
+ // without trapping JSON.parse<string> in the guest first.
30
+ if (handle <= 0) return changetype<i32>('""')
31
+ const text = __pulse_fastly_json(handle, 0)
32
+ if (String.UTF8.byteLength(text) > ${CANONICAL_NATIVE_VALUE_TRANSFER_MAX_BYTES}) {
33
+ __pulse_fastly_fail(PULSE_ERROR_VALUE, 172, -1); return changetype<i32>('null')
34
+ }
35
+ return changetype<i32>(text)
36
+ }`;
37
+ const guard = name === 'log' ? `if (level <= 0 || level > ${Number(plan.logging.reporting.level)}) return; ` : '';
38
+ return `function ${symbol}(${parameters}): ${result} { ${guard}${result === 'void' ? '' : 'return '}host_${name}(${args}) }`;
39
+ }
40
+ );
41
+ if (source.includes('@external("pulse_host",')) throw new Error('Unbound package Native host import.');
42
+ const codecs = (plan.schemas?.registry?.schemas || []).map((_schema, index) => `
43
+ function __pulse_schema_decode_${index}(input: string): string { return changetype<string>(pulse_schema_decode(${index}, changetype<i32>(input))) }
44
+ function __pulse_schema_encode_${index}(input: string): string { return changetype<string>(pulse_schema_encode(${index}, changetype<i32>(input))) }`).join('\n');
45
+ return { ...application, source: source + codecs + `\nconst __pulse_application_plan_hash: string = ${JSON.stringify(plan.planHash)}\n` };
46
+ }
47
+
48
+ function runtimeSource() {
49
+ return `
50
+ const __pulse_package_failed = new StaticArray<i32>(PULSE_FASTLY_EFFECT_COUNT)
51
+ function __pulse_package_take_effect_failure(): bool {
52
+ const code = __pulse_fastly_last_error
53
+ // Deadlines, invalid lifecycle transitions and request/body failures remain
54
+ // provider-terminal. Only an admitted effect's operational failure is framed.
55
+ if (__pulse_fastly_error_stage == 260 || __pulse_fastly_error_stage == 261) return false
56
+ if (code != PULSE_ERROR_HOSTCALL && code != PULSE_ERROR_TRANSPORT
57
+ && code != PULSE_ERROR_JSON && code != PULSE_ERROR_SCHEMA && code != PULSE_ERROR_VALUE) return false
58
+ __pulse_fastly_last_error = 0; __pulse_fastly_error_stage = 0; __pulse_fastly_error_effect = -1
59
+ return true
60
+ }
61
+ function host_effect_begin(effectIndex: i32, payload: i32): void {
62
+ if (__pulse_fastly_last_error != 0) return
63
+ __pulse_package_effect_begin(effectIndex, payload)
64
+ if (__pulse_package_take_effect_failure()) unchecked(__pulse_package_failed[effectIndex] = 1)
65
+ }
66
+ `;
67
+ }
68
+
69
+ function instrument(source) {
70
+ const signature = 'function host_effect_begin(effectIndex: i32, payload: i32): void {';
71
+ if (!source.includes(signature)) throw new Error('Fastly package effect admission boundary drift.');
72
+ return source.replace(signature, 'function __pulse_package_effect_begin(effectIndex: i32, payload: i32): void {') + runtimeSource();
73
+ }
74
+
75
+ function driverLoop() {
76
+ return ` while (runStatus == 1 && __pulse_fastly_last_error == 0) {
77
+ for (let effectIndex = 0; effectIndex < PULSE_FASTLY_EFFECT_COUNT; effectIndex += 1) {
78
+ const invocation = unchecked(__pulse_invocation_tickets[effectIndex])
79
+ if (invocation == 0) continue
80
+ let failed = unchecked(__pulse_package_failed[effectIndex]) != 0
81
+ unchecked(__pulse_package_failed[effectIndex] = 0)
82
+ let result = failed ? 0 : __pulse_fastly_resolve_effect(effectIndex)
83
+ if (__pulse_package_take_effect_failure()) { failed = true; result = 0 }
84
+ if (__pulse_fastly_last_error != 0) { __pulse_fastly_jwt_send_error(); return }
85
+ if (!failed && result <= 0) { __pulse_fastly_fail(PULSE_ERROR_STATE, 101, effectIndex); __pulse_fastly_jwt_send_error(); return }
86
+ if (__pulse_invocation_settle(effectIndex, invocation, result) != 1 || __pulse_fastly_last_error != 0) {
87
+ __pulse_fastly_fail(PULSE_ERROR_STATE, 101, effectIndex); __pulse_fastly_jwt_send_error(); return
88
+ }
89
+ }
90
+ runStatus = pulse_resume()
91
+ }`;
92
+ }
93
+
94
+ module.exports = { enabled, portable, instrument, driverLoop };
@@ -18,11 +18,12 @@ function instrumentRequestBudget(input, duration, plan) {
18
18
  const handles = new StaticArray<i32>(2)
19
19
  const downstreamStatus`);
20
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('function __pulse_invocation_settle(index: i32, ticket: i32, result: i32): i32 {', 'function __pulse_invocation_settle(index: i32, ticket: i32, result: i32): i32 {\n if (!__request_check(index)) return 0;');
21
22
  replace(' let runStatus = pulse_start()', ' if (!__request_check()) { __request_send_timeout(); return; }\n let runStatus = pulse_start()');
22
23
  replace('function __pulse_fastly_resolve_effect(effectIndex: i32): i32 {', `function __pulse_fastly_resolve_effect(effectIndex: i32): i32 {
23
24
  if (!__request_check(effectIndex)) return 0;
24
25
  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 (unchecked(__pulse_fastly_pending_mode[effectIndex]) == PULSE_FASTLY_PENDING_ASYNC && kind != 12 && kind != 13 && kind != 14 && kind != 21 && ${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
27
  if (!__request_wait(unchecked(__pulse_fastly_pending[effectIndex]), effectIndex)) return 0;
27
28
  }`);
28
29
  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();');
@@ -38,6 +39,7 @@ function instrumentRequestBudget(input, duration, plan) {
38
39
  replace(' const result = pulse_result_handle()', ' if (!__request_check()) { __request_send_timeout(); return; }\n const result = pulse_result_handle()');
39
40
  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
41
  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);');
42
+ if (source.includes('function __pulse_fastly_write_binary(')) replace('const written = new StaticArray<i32>(1); let offset = 0;\n while (offset < data.length) {', 'const written = new StaticArray<i32>(1); let offset = 0;\n while (offset < data.length) { if (!__request_check()) return 1;');
41
43
  return source + '\n' + fs.readFileSync(path.join(__dirname,'request-budget.as.ts'),'utf8');
42
44
  }
43
45
  module.exports = {instrumentRequestBudget};
@@ -0,0 +1,97 @@
1
+ // Fastly owns opaque body handles. No bytes enter an application value or text codec.
2
+ @lazy const __s3_bodies = new Array<__PulseFastlyValue>()
3
+ function __s3_body_failure(reason: string): i32 {
4
+ const output = new __PulseFastlyValue(); output.kind = PULSE_VALUE_RESPONSE;
5
+ output.status = reason == 'timeout' ? 504 : 502;
6
+ output.headers.push(new __PulseFastlyHeader('x-pulse-s3-error', reason));
7
+ return __pulse_fastly_put(output);
8
+ }
9
+ function __s3_close_bodies(): void {
10
+ for (let i = 0; i < __s3_bodies.length; i++) {
11
+ const value = __s3_bodies[i];
12
+ if (value.s3Body) { __s3_close_body(value.bodyHandle); value.s3Body = false; }
13
+ }
14
+ __s3_bodies.length = 0;
15
+ }
16
+ function __s3_body_response(index: i32, response: i32, body: i32, status: i32): i32 {
17
+ const output = new __PulseFastlyValue(); output.kind = PULSE_VALUE_RESPONSE; output.status = status;
18
+ const buffer = new Uint8Array(16384), end = new StaticArray<i64>(1), size = new StaticArray<i32>(1);
19
+ if (__s3_header_names(response, buffer.dataStart, buffer.length, 0, changetype<usize>(end), changetype<usize>(size)) != 0 || end[0] >= 0) return __s3_body_failure('protocol');
20
+ const names = __s3_list(buffer, size[0]); if (names === null) return __s3_body_failure('protocol');
21
+ let total = 0, length: f64 = -1, contentRange = ''; const seen = new Set<string>();
22
+ for (let i = 0; i < names.length; i++) {
23
+ const name = names[i].toLowerCase(), bytes = __pulse_s3_bytes(names[i]);
24
+ if (__s3_header_values(response, bytes.dataStart, bytes.length, buffer.dataStart, buffer.length, 0, changetype<usize>(end), changetype<usize>(size)) != 0 || end[0] >= 0) return __s3_body_failure('protocol');
25
+ const values = __s3_list(buffer, size[0]); if (values === null) return __s3_body_failure('protocol');
26
+ const selected = name == 'content-length' || name == 'content-type' || name == 'etag' || name == 'content-encoding' || name == 'content-range';
27
+ if (selected && (seen.has(name) || values.length != 1)) return __s3_body_failure('protocol');
28
+ seen.add(name);
29
+ for (let v = 0; v < values.length; v++) {
30
+ const value = values[v], count = __pulse_s3_bytes(value).length;
31
+ total += bytes.length + count + 4; if (total > 16384) return __s3_body_failure('protocol');
32
+ if (!selected) continue;
33
+ if (count > 1024 || !__pulse_s3_scalar(value, true)) return __s3_body_failure('protocol');
34
+ if (name == 'content-encoding') { if (value.toLowerCase() != 'identity') return __s3_body_failure('protocol'); continue; }
35
+ if (name == 'content-length') {
36
+ length = __pulse_s3_decimal(value); if (length < 0) return __s3_body_failure('protocol');
37
+ if (status != 200 && status != 206) continue;
38
+ }
39
+ if (name == 'etag' && !value.length) return __s3_body_failure('protocol');
40
+ if (name == 'content-range') { if (!value.length || count > 128) return __s3_body_failure('protocol'); contentRange = value; }
41
+ output.headers.push(new __PulseFastlyHeader(name, value));
42
+ }
43
+ }
44
+ const binding = __pulse_fastly_s3_binding(index);
45
+ if ((status == 200 || status == 206) && length < 0) return __s3_body_failure('protocol');
46
+ if (!__pulse_s3_body_range(status, length, contentRange, binding.range, binding.ifNoneMatch.length > 0)) return __s3_body_failure('protocol');
47
+ if (__s3_remaining(index) == 0) return __s3_body_failure('timeout');
48
+ if (binding.method != 'HEAD' && (status == 200 || status == 206)) {
49
+ if (length > binding.max) return __s3_body_failure('too-large');
50
+ output.s3Body = true; output.bodyHandle = body; output.s3Length = i32(length); output.s3Deadline = __s3_deadlines[index];
51
+ __s3_bodies.push(output);
52
+ }
53
+ return __pulse_fastly_put(output);
54
+ }
55
+ function __s3_body_remaining(value: __PulseFastlyValue): i32 {
56
+ const now = __s3_clock(1), ns = value.s3Deadline - now;
57
+ return now < 0 || ns <= 0 ? 0 : i32((ns + 999999) / 1000000);
58
+ }
59
+ function __s3_body_wait(value: __PulseFastlyValue, handle: i32): bool {
60
+ const timeout = __s3_body_remaining(value); if (!timeout) return false;
61
+ const handles = new StaticArray<i32>(1), done = new StaticArray<i32>(1); handles[0] = handle;
62
+ return __s3_select(changetype<usize>(handles), 1, timeout, changetype<usize>(done)) == 0 && done[0] != -1 && __s3_body_remaining(value) > 0;
63
+ }
64
+ function __s3_copy_body(value: __PulseFastlyValue, output: i32): bool {
65
+ const buffer = new Uint8Array(16384), size = new StaticArray<i32>(1), written = new StaticArray<i32>(1);
66
+ let count = 0;
67
+ while (true) {
68
+ if (!__s3_body_wait(value, value.bodyHandle)) return false;
69
+ if (fastly_http_body_read(value.bodyHandle, buffer.dataStart, buffer.length, changetype<usize>(size)) != 0 || size[0] < 0 || size[0] > buffer.length) return false;
70
+ if (__s3_body_remaining(value) == 0) return false;
71
+ if (!size[0]) return count == value.s3Length;
72
+ count += size[0]; if (count > value.s3Length) return false;
73
+ let offset = 0;
74
+ while (offset < size[0]) {
75
+ if (!__s3_body_wait(value, output)) return false;
76
+ if (fastly_http_body_write(output, buffer.dataStart + offset, size[0] - offset, 0, changetype<usize>(written)) != 0 || written[0] <= 0 || written[0] > size[0] - offset) return false;
77
+ offset += written[0];
78
+ }
79
+ }
80
+ }
81
+ function __s3_send_body(value: __PulseFastlyValue): i32 {
82
+ if (!__s3_body_remaining(value)) return __pulse_fastly_send_result(__s3_body_failure('timeout'));
83
+ const response = new StaticArray<i32>(1), body = new StaticArray<i32>(1);
84
+ if (fastly_http_resp_new(changetype<usize>(response)) != 0) return 1;
85
+ if (fastly_http_body_new(changetype<usize>(body)) != 0) { __s3_close_resp(response[0]); return 1; }
86
+ let ok = fastly_http_resp_status_set(response[0], value.status) == 0;
87
+ for (let i = 0; ok && i < value.headers.length; i++) {
88
+ const header = value.headers[i], name = __pulse_s3_bytes(header.name), data = __pulse_s3_bytes(header.value);
89
+ ok = fastly_http_resp_header_append(response[0], name.dataStart, name.length, data.dataStart, data.length) == 0;
90
+ }
91
+ if (!ok || fastly_http_resp_send_downstream(response[0], body[0], 1) != 0) { __s3_close_resp(response[0]); __s3_close_body(body[0]); return 1; }
92
+ ok = __s3_copy_body(value, body[0]);
93
+ __s3_close_body(value.bodyHandle); value.s3Body = false;
94
+ // close is successful EOF for a streaming handle. On failure leave it unfinished:
95
+ // Compute aborts the downstream stream when this invocation exits (SDK semantics).
96
+ return ok ? __s3_close_body(body[0]) : 1;
97
+ }
@@ -10,6 +10,7 @@
10
10
  @external('fastly_http_resp', 'header_values_get') declare function __s3_header_values(response: i32, name: usize, length: i32, buffer: usize, size: i32, cursor: i32, end: usize, written: usize): i32
11
11
 
12
12
  class __PulseS3Binding {
13
+ method: string = ''; range: string = ''; ifNoneMatch: string = '';
13
14
  contentType: string = ''; endpoint: string = ''; bucket: string = ''; region: string = ''; backend: string = '';
14
15
  id: string = ''; secret: string = ''; token: string = ''; max: i32 = 32768; timeout: i32 = 10000;
15
16
  }
@@ -35,6 +36,7 @@ function __s3_result(status: string, reason: string = '', http: i32 = 0): i32 {
35
36
  return result;
36
37
  }
37
38
  function __s3_failure(index: i32, reason: string, http: i32 = 0): i32 {
39
+ if (__pulse_fastly_effect_kind(index) == 21) return __s3_body_failure(reason);
38
40
  return __s3_result(__pulse_fastly_effect_kind(index) == 14 ? (__s3_dispatched[index] ? 'unknown' : 'not-stored') : 'failed', reason, http);
39
41
  }
40
42
  function __s3_fail(index: i32, reason: string): void { __pulse_fastly_ready_effect(index, __s3_failure(index, reason)) }
@@ -84,10 +86,10 @@ function __pulse_fastly_s3_begin(index: i32, outer: __PulseFastlyValue): void {
84
86
  const token = binding.token.length ? __s3_secret(store[0], binding.token) : '';
85
87
  if (__s3_remaining(index) == 0) { __s3_fail(index, 'timeout'); return; }
86
88
  if (id === null || secret === null || token === null || !__pulse_s3_credentials(id, secret, token)) { __s3_fail(index, 'credentials'); return; }
87
- const method = put ? 'PUT' : __pulse_fastly_effect_kind(index) == 12 ? 'HEAD' : 'GET', path = '/' + binding.bucket + '/' + encoded;
89
+ const method = binding.method.length ? binding.method : put ? 'PUT' : __pulse_fastly_effect_kind(index) == 12 ? 'HEAD' : 'GET', path = '/' + binding.bucket + '/' + encoded;
88
90
  const now = __s3_clock(0);
89
91
  if (now < 0 || !__pulse_s3_date(now / 1000000000).length) { __s3_fail(index, 'configuration'); return; }
90
- const signature = __pulse_s3_sign(method, path, binding.endpoint.slice(8), binding.region, id, secret, token, now / 1000000000, data, binding.contentType);
92
+ const signature = __pulse_s3_sign(method, path, binding.endpoint.slice(8), binding.region, id, secret, token, now / 1000000000, data, binding.contentType, binding.range, binding.ifNoneMatch);
91
93
  __s3_sent_bytes[index] = data.length; __s3_digests[index] = signature.digest;
92
94
  __pulse_fastly_remember_secret(signature.digest);
93
95
  __pulse_fastly_remember_secret(signature.authorization); __pulse_fastly_remember_secret(binding.endpoint + path);
@@ -100,6 +102,8 @@ function __pulse_fastly_s3_begin(index: i32, outer: __PulseFastlyValue): void {
100
102
  && __s3_header(req, 'host', binding.endpoint.slice(8)) && __s3_header(req, 'x-amz-date', signature.date)
101
103
  && __s3_header(req, 'x-amz-content-sha256', signature.digest) && __s3_header(req, 'authorization', signature.authorization)
102
104
  && __s3_header(req, 'accept-encoding', 'identity');
105
+ if (ok && binding.range.length) ok = __s3_header(req, 'range', binding.range);
106
+ if (ok && binding.ifNoneMatch.length) ok = __s3_header(req, 'if-none-match', binding.ifNoneMatch);
103
107
  if (ok && put) ok = __s3_header(req, 'content-type', binding.contentType) && __s3_header(req, 'content-length', data.length.toString());
104
108
  if (ok && token.length) ok = __s3_header(req, 'x-amz-security-token', token);
105
109
  if (!ok || fastly_http_body_new(changetype<usize>(body)) != 0) { __s3_close_req(req); __s3_fail(index, 'transport'); return; }
@@ -135,6 +139,7 @@ function __s3_list(buffer: Uint8Array, size: i32): Array<string> | null {
135
139
  return result;
136
140
  }
137
141
  function __s3_read_response(index: i32, response: i32, body: i32, status: i32): i32 {
142
+ if (__pulse_fastly_effect_kind(index) == 21) return __s3_body_response(index, response, body, status);
138
143
  const put = __pulse_fastly_effect_kind(index) == 14;
139
144
  const rejected = put && status >= 400 && status <= 499 && status != 408;
140
145
  if (!put && status == 404) return __s3_result('not-found');
@@ -213,5 +218,6 @@ function __pulse_fastly_s3_wait(index: i32): i32 {
213
218
  let result: i32;
214
219
  if (fastly_http_resp_status_get(response[0], changetype<usize>(code)) != 0) result = __s3_failure(index, 'protocol');
215
220
  else result = __s3_remaining(index) == 0 ? __s3_failure(index, 'timeout', code[0]) : __s3_read_response(index, response[0], body[0], code[0]);
216
- __s3_close_body(body[0]); __s3_close_resp(response[0]); return result;
221
+ if (!__pulse_fastly_value(result).s3Body) __s3_close_body(body[0]);
222
+ __s3_close_resp(response[0]); return result;
217
223
  }
@@ -21,10 +21,10 @@ function s3NativeSource(plan, bindings) {
21
21
  const lines = ['function __pulse_fastly_s3_binding(index: i32): __PulseS3Binding {', 'const value = new __PulseS3Binding();', 'switch (index) {'];
22
22
  for (const binding of bindings.s3) {
23
23
  lines.push(`case ${binding.index}:`);
24
- for (const [field, input] of Object.entries({ contentType: 'contentType', endpoint: 'endpoint', bucket: 'bucket', region: 'region', backend: 'backend', id: 'accessKeyIdSecret', secret: 'secretAccessKeySecret', token: 'sessionTokenSecret' })) lines.push(`value.${field} = ${JSON.stringify(binding[input] || '')};`);
24
+ for (const [field, input] of Object.entries({ method: 'method', range: 'range', ifNoneMatch: 'ifNoneMatch', contentType: 'contentType', endpoint: 'endpoint', bucket: 'bucket', region: 'region', backend: 'backend', id: 'accessKeyIdSecret', secret: 'secretAccessKeySecret', token: 'sessionTokenSecret' })) lines.push(`value.${field} = ${JSON.stringify(binding[input] || '')};`);
25
25
  lines.push(`value.max = ${binding.maxTextBytes}; value.timeout = ${binding.timeoutMs}; break;`);
26
26
  }
27
27
  lines.push('default: unreachable();', '}', 'return value;', '}');
28
- return [contribution.source, fs.readFileSync(path.join(__dirname, 's3-native.as.ts'), 'utf8'), lines.join('\n')].join('\n');
28
+ return [contribution.source, fs.readFileSync(path.join(__dirname, 's3-native.as.ts'), 'utf8'), fs.readFileSync(path.join(__dirname, 's3-body.as.ts'), 'utf8'), lines.join('\n')].join('\n');
29
29
  }
30
30
  module.exports = { S3_IMPORTS, s3NativeSource };
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ const { schemaAdmission } = require('@pulse-compute/wasm-runtime-core-as/compiler');
4
+
5
+ function fastlyJsonValueAdmissionSource() {
6
+ return `
7
+ class __PulseFastlyAdmissionFrame {
8
+ index: i32 = 0
9
+ constructor(public handle: i32) {}
10
+ }
11
+ function __pulse_fastly_admission_enter(handle: i32, budget: __PulseJsonAdmissionBudget,
12
+ stack: Array<__PulseFastlyAdmissionFrame>, active: Set<i32>): bool {
13
+ if (handle <= 0 || handle > __pulse_fastly_values.length) return budget.fail(12)
14
+ const value = __pulse_fastly_values[handle - 1]
15
+ const array = value.kind == PULSE_VALUE_ARRAY
16
+ const object = value.kind == PULSE_VALUE_OBJECT
17
+ if (!budget.node(stack.length + (array || object ? 1 : 0))) return false
18
+ if (value.kind == PULSE_VALUE_NULL) return budget.addBytes(4)
19
+ if (value.kind == PULSE_VALUE_BOOLEAN) return budget.addBytes(value.boolean != 0 ? 4 : 5)
20
+ if (value.kind == PULSE_VALUE_NUMBER) return isFinite(value.number) ? budget.addBytes(24) : budget.fail(11)
21
+ if (value.kind == PULSE_VALUE_STRING) return budget.stringBytes(value.text, false)
22
+ if (!array && !object) return budget.fail(12)
23
+ if (active.has(handle)) return budget.fail(13)
24
+ if (array && value.values.length > budget.limits.maxArrayItems) return budget.fail(6)
25
+ if (object && value.keys.length > budget.limits.maxObjectMembers) return budget.fail(5)
26
+ if (object && value.keys.length != value.values.length) return budget.fail(12)
27
+ if (!budget.addBytes(2)) return false
28
+ active.add(handle)
29
+ stack.push(new __PulseFastlyAdmissionFrame(handle))
30
+ return true
31
+ }
32
+ function __pulse_fastly_admit_bounded_value(handle: i32, limits: __PulseJsonAdmissionLimits): __PulseJsonAdmissionBudget {
33
+ const budget = new __PulseJsonAdmissionBudget(limits)
34
+ const stack = new Array<__PulseFastlyAdmissionFrame>()
35
+ const active = new Set<i32>()
36
+ if (!__pulse_fastly_admission_enter(handle, budget, stack, active)) return budget
37
+ while (stack.length > 0) {
38
+ const frame = stack[stack.length - 1]
39
+ const value = __pulse_fastly_values[frame.handle - 1]
40
+ if (frame.index == value.values.length) { active.delete(frame.handle); stack.pop(); continue }
41
+ const index = frame.index++
42
+ if (index > 0 && !budget.addBytes(1)) return budget
43
+ if (value.kind == PULSE_VALUE_OBJECT) {
44
+ if (!budget.stringBytes(value.keys[index], true) || !budget.addBytes(1)) return budget
45
+ }
46
+ if (!__pulse_fastly_admission_enter(value.values[index], budget, stack, active)) return budget
47
+ }
48
+ return budget
49
+ }
50
+ `;
51
+ }
52
+
53
+ // Standalone composition used by focused admission fixtures.
54
+ function generateFastlyJsonAdmission(limits, parserMaxDepth = 64) {
55
+ const generated = schemaAdmission.generateJsonAdmission(limits);
56
+ if (!Number.isInteger(parserMaxDepth) || parserMaxDepth < 1 || parserMaxDepth > 0x7fffffff
57
+ || generated.identity.limits.maxDepth > parserMaxDepth) {
58
+ throw new TypeError('JSON admission depth exceeds the selected Fastly parser capacity.');
59
+ }
60
+ return Object.freeze({ identity: generated.identity, source: `${generated.source}
61
+ ${fastlyJsonValueAdmissionSource()}
62
+ function __pulse_fastly_admit_value(handle: i32): __PulseJsonAdmissionBudget {
63
+ return __pulse_fastly_admit_bounded_value(handle, __pulse_json_limits())
64
+ }
65
+ function __pulse_fastly_admitted_json(text: string, duplicateMode: i32 = 0): i32 {
66
+ const admission = __pulse_json_admit(text, duplicateMode)
67
+ if (admission.failure != 0) { __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 57, -1); return 0 }
68
+ return __pulse_fastly_parse_json(text)
69
+ }
70
+ function __pulse_fastly_admitted_json_text(handle: i32): string {
71
+ const admission = __pulse_fastly_admit_value(handle)
72
+ if (admission.failure != 0) { __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 57, -1); return '' }
73
+ const text = __pulse_fastly_json(handle, 0)
74
+ if (String.UTF8.byteLength(text) > admission.limits.maxTextBytes) {
75
+ __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 57, -1)
76
+ return ''
77
+ }
78
+ return text
79
+ }
80
+ ` });
81
+ }
82
+
83
+ module.exports = { generateFastlyJsonAdmission, fastlyJsonValueAdmissionSource };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const { schemaHasNestedJson, schemaHasScalarRecord } = require('@pulse-compute/wasm-contracts/schema-json/registry');
4
+ const { fastlyJsonValueAdmissionSource } = require('./schema-admission.js');
5
+
6
+ function hasJsonAdmission(plan) { return (plan.schemas?.registry?.schemas || []).some(schema => schema.jsonLimits); }
7
+ function tracksJsonDuplicates(plan) {
8
+ return (plan.schemas?.registry?.schemas || []).some(schema => schemaHasNestedJson(schema.root) || schemaHasScalarRecord(schema.root));
9
+ }
10
+ function schemaParserDepth(plan, baseline = 64) {
11
+ return Math.max(baseline, ...(plan.schemas?.registry?.schemas || []).map(schema => schema.jsonLimits?.maxDepth || 0));
12
+ }
13
+
14
+ function nestedJsonProjectionLines(node) {
15
+ return [
16
+ ...(node.kind === 'json-object' ? [' if (input.kind != PULSE_VALUE_OBJECT) { __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 52, -1); return 0 }'] : []),
17
+ ' return __pulse_fastly_copy_json(valueHandle, checkDuplicates)'
18
+ ];
19
+ }
20
+
21
+ function schemaFetchJsonSource() {
22
+ return `
23
+ function __pulse_fastly_fetch_json_headers(input: __PulseFastlyValue): __PulseFastlyValue {
24
+ for (let index = 0; index < input.keys.length; index++) if (input.keys[index].toLowerCase() == "content-type") return input
25
+ const output = new __PulseFastlyValue()
26
+ output.kind = PULSE_VALUE_OBJECT
27
+ for (let index = 0; index < input.keys.length; index++) {
28
+ output.keys.push(input.keys[index]); output.values.push(input.values[index])
29
+ }
30
+ output.keys.push("content-type")
31
+ output.values.push(__pulse_fastly_string_value("application/json; charset=utf-8"))
32
+ return output
33
+ }
34
+ function __pulse_fastly_fetch_json_body(init: __PulseFastlyValue, valueHandle: i32): string {
35
+ const index = __pulse_fastly_find(init, "schema")
36
+ let value = valueHandle
37
+ if (index >= 0) {
38
+ const schema = __pulse_fastly_value(init.values[index])
39
+ if (schema.kind != PULSE_VALUE_STRING || schema.text.length == 0) { __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 55, -1); return '' }
40
+ value = __pulse_fastly_schema_apply(schema.text, valueHandle, true)
41
+ if (value <= 0 || __pulse_fastly_last_error != 0) return ''
42
+ }
43
+ return __pulse_fastly_json(value, 0)
44
+ }
45
+ `;
46
+ }
47
+
48
+ function schemaJsonAdmissionSource(plan) {
49
+ const schemas = plan.schemas?.registry?.schemas || [];
50
+ if (!hasJsonAdmission(plan)) return '';
51
+ const nested = schemas.some(schema => schemaHasNestedJson(schema.root));
52
+ return `${fastlyJsonValueAdmissionSource()}
53
+ function __pulse_fastly_schema_parse_json(text: string, schemaHandle: i32): i32 {
54
+ const schema = __pulse_fastly_value(schemaHandle)
55
+ if (schema.kind == PULSE_VALUE_STRING) {
56
+ ${schemas.map((schema, index) => schema.jsonLimits ? ` if (schema.text == ${JSON.stringify(schema.id)} && __pulse_schema_json_scan_${index}(text, 0).failure != 0) {
57
+ __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 57, -1); return 0
58
+ }` : '').join('\n')}
59
+ }
60
+ return __pulse_fastly_parse_json(text)
61
+ }
62
+ ${nested ? `
63
+ class __PulseFastlyJsonCopyFrame {
64
+ index: i32 = 0
65
+ constructor(public input: i32, public output: i32) {}
66
+ }
67
+ function __pulse_fastly_json_copy_enter(handle: i32, duplicates: bool, frames: Array<__PulseFastlyJsonCopyFrame>): i32 {
68
+ const value = __pulse_fastly_value(handle)
69
+ if (value.kind != PULSE_VALUE_ARRAY && value.kind != PULSE_VALUE_OBJECT) return handle
70
+ if (duplicates && value.duplicateJsonKeys) { __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 57, -1); return 0 }
71
+ const copy = value.kind == PULSE_VALUE_ARRAY ? host_value_array() : host_value_object()
72
+ frames.push(new __PulseFastlyJsonCopyFrame(handle, copy))
73
+ return copy
74
+ }
75
+ function __pulse_fastly_copy_json(handle: i32, duplicates: bool): i32 {
76
+ const frames = new Array<__PulseFastlyJsonCopyFrame>()
77
+ const output = __pulse_fastly_json_copy_enter(handle, duplicates, frames)
78
+ while (frames.length > 0) {
79
+ const frame = frames[frames.length - 1]
80
+ const value = __pulse_fastly_value(frame.input)
81
+ if (frame.index == value.values.length) { frames.pop(); continue }
82
+ const index = frame.index++
83
+ const copy = __pulse_fastly_json_copy_enter(value.values[index], duplicates, frames)
84
+ if (copy <= 0) return 0
85
+ if (value.kind == PULSE_VALUE_ARRAY) host_value_array_push(frame.output, copy)
86
+ else host_value_object_set(frame.output, __pulse_fastly_string_value(value.keys[index]), copy)
87
+ }
88
+ return output
89
+ }
90
+ ` : ''}`;
91
+ }
92
+
93
+ module.exports = { hasJsonAdmission, tracksJsonDuplicates, schemaParserDepth, nestedJsonProjectionLines, schemaJsonAdmissionSource, schemaFetchJsonSource };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const { schemaHasScalarRecord } = require('@pulse-compute/wasm-contracts/schema-json/registry');
4
+
5
+ function hasScalarRecords(plan) {
6
+ return (plan.schemas?.registry?.schemas || []).some(schema => schemaHasScalarRecord(schema.root));
7
+ }
8
+
9
+ // Both Fastly Native realizations validate handles before json-as sees them.
10
+ function scalarRecordProjectionLines(node) {
11
+ const limits = node.limits;
12
+ return [
13
+ ' if (input.kind != PULSE_VALUE_OBJECT || (checkDuplicates && input.duplicateJsonKeys)) { __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 56, -1); return 0 }',
14
+ ` if (input.keys.length > ${limits.maxKeys}) { __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 56, -1); return 0 }`,
15
+ ' const keys = input.keys.slice().sort()',
16
+ ' const output = host_value_object()',
17
+ ' let bytes = 2',
18
+ ' for (let i = 0; i < keys.length; i++) {',
19
+ ' const key = keys[i]',
20
+ ` if (key.length > ${limits.maxKeyLength}) { __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 56, -1); return 0 }`,
21
+ ' const keyHandle = __pulse_fastly_string_value(key)',
22
+ ' const itemHandle = host_value_property(valueHandle, keyHandle)',
23
+ ' const item = __pulse_fastly_value(itemHandle)',
24
+ ' let size = 0',
25
+ ' if (item.kind == PULSE_VALUE_STRING) {',
26
+ ` if (item.text.length > ${limits.maxStringLength}) { __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 56, -1); return 0 }`,
27
+ ' size = __pulse_scalar_record_string_bytes(item.text)',
28
+ ' } else if (item.kind == PULSE_VALUE_NUMBER && isFinite(item.number)) {',
29
+ ` size = ${limits.numberBytes}`,
30
+ ' } else if (item.kind == PULSE_VALUE_BOOLEAN) size = item.boolean != 0 ? 4 : 5',
31
+ ' else if (item.kind == PULSE_VALUE_NULL) size = 4',
32
+ ' else { __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 56, -1); return 0 }',
33
+ ' bytes += (i == 0 ? 0 : 1) + __pulse_scalar_record_string_bytes(key) + 1 + size',
34
+ ` if (bytes > ${limits.maxBytes}) { __pulse_fastly_fail(PULSE_ERROR_SCHEMA, 56, -1); return 0 }`,
35
+ ' host_value_object_set(output, keyHandle, itemHandle)',
36
+ ' }',
37
+ ' return output'
38
+ ];
39
+ }
40
+
41
+ module.exports = { hasScalarRecords, scalarRecordProjectionLines };
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ const { fastlyConfigDefault } = require('../config-schema.js');
4
+ const DEFAULT_FASTLY_MAX_WASM_BYTES = fastlyConfigDefault('fastly.build.maxWasmBytes');
5
+
6
+ function normalizeFastlyMaxWasmBytes(value) {
7
+ if (value === undefined) return DEFAULT_FASTLY_MAX_WASM_BYTES;
8
+ if (!Number.isSafeInteger(value) || value <= 0) {
9
+ const error = new TypeError('fastly.build.maxWasmBytes must be a positive safe integer byte count.');
10
+ error.code = 'PULSE_FASTLY_MAX_WASM_BYTES_INVALID';
11
+ throw error;
12
+ }
13
+ return value;
14
+ }
15
+
16
+ function assertFastlyWasmBudget(bytes, value) {
17
+ const maxBytes = normalizeFastlyMaxWasmBytes(value);
18
+ if (bytes > maxBytes) {
19
+ const error = new Error('Generated Fastly Native module exceeds the configured Pulse compilation budget.');
20
+ error.name = 'FastlyNativePlatformCapabilitiesError';
21
+ error.code = 'PULSE_FASTLY_NATIVE_PLATFORM_CAPABILITIES_WASM_TOO_LARGE';
22
+ error.detail = Object.freeze({ bytes, maxBytes });
23
+ throw error;
24
+ }
25
+ return maxBytes;
26
+ }
27
+
28
+ module.exports = Object.freeze({ DEFAULT_FASTLY_MAX_WASM_BYTES, normalizeFastlyMaxWasmBytes, assertFastlyWasmBudget });
package/src/config-api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const { normalizeFastlyMaxWasmBytes } = require('./build/wasm-budget.js');
3
4
  const { fastlyConfigDefault } = require('./config-schema.js');
4
5
 
5
6
  const FASTLY_PROVIDER_API_VERSION = 'pulse.provider-fastly-api.v2';
@@ -49,6 +50,7 @@ function fastly(options = {}) {
49
50
  dynamicBackends: options.dynamicBackends === undefined ? fastlyConfigDefault('fastly.dynamicBackends') : options.dynamicBackends === true
50
51
  }),
51
52
  build: Object.freeze({
53
+ maxWasmBytes: normalizeFastlyMaxWasmBytes(options.maxWasmBytes),
52
54
  name: String(options.name ?? fastlyConfigDefault('fastly.name')),
53
55
  description: String(options.description ?? fastlyConfigDefault('fastly.description')),
54
56
  authors: Object.freeze((Array.isArray(options.authors) ? options.authors : fastlyConfigDefault('fastly.authors')).map(String)),
@@ -75,6 +75,13 @@
75
75
  },
76
76
  "fastly.maxDurationMs": {
77
77
  "kind": "optional-number"
78
+ },
79
+ "fastly.build.maxWasmBytes": {
80
+ "kind": "positive-safe-integer",
81
+ "default": 4194304
82
+ },
83
+ "fastly.maxWasmBytes": {
84
+ "kind": "positive-safe-integer"
78
85
  }
79
86
  }
80
87
  }