@pulse-compute/provider-fastly 1.0.0-beta.1 → 1.0.0-beta.2
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 +7 -6
- package/dist/config-schema.json +4 -0
- package/package.json +11 -10
- package/src/build/kv-native.as.ts +229 -0
- package/src/build/kv-native.js +20 -0
- package/src/build/native-platform-capabilities.js +129 -42
- package/src/build/s3-native.as.ts +215 -0
- package/src/build/s3-native.js +30 -0
- package/src/config-schema.json +4 -0
- package/src/javascript/target-support-policy.js +4 -0
- package/src/provider-contract.js +9 -2
- package/src/runtime/canonical-api-runtime.js +4 -0
- package/src/testing/conditional-kv-host.js +109 -0
- package/src/testing/native-platform-capabilities-host.js +90 -13
- package/src/toolchain/config.js +3 -0
- package/src/toolchain/index.js +3 -1
- package/src/toolchain/s3.js +26 -0
package/src/config-schema.json
CHANGED
|
@@ -16,6 +16,8 @@ function blockedRestriction(id, restriction) {
|
|
|
16
16
|
|
|
17
17
|
function classifyFastlyJavascriptCapability(id, restrictions = {}) {
|
|
18
18
|
const capability = String(id);
|
|
19
|
+
if (['kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap'].includes(capability)) return decision(capability, 'blocked', 'fastly-conditional-kv-incomplete', 'provider-fastly');
|
|
20
|
+
if (['s3.head', 's3.getText', 's3.putText'].includes(capability)) return decision(capability, 'blocked', 'fastly-javascript-s3-raw-headers-unavailable', 'provider-fastly');
|
|
19
21
|
if (isJavascriptCoreCapability(capability)) {
|
|
20
22
|
return decision(capability, 'eligible', 'fastly-request-router-runtime', 'provider-fastly');
|
|
21
23
|
}
|
|
@@ -100,6 +102,8 @@ function classifyFastlyJavascriptCapability(id, restrictions = {}) {
|
|
|
100
102
|
|
|
101
103
|
function classifyFastlyJavascriptProviderRequirement(id, restrictions = {}) {
|
|
102
104
|
const requirement = String(id);
|
|
105
|
+
if (['kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap'].includes(requirement)) return decision(requirement, 'blocked', 'fastly-conditional-kv-incomplete', 'provider-fastly');
|
|
106
|
+
if (['s3.head', 's3.getText', 's3.putText'].includes(requirement)) return decision(requirement, 'blocked', 'fastly-javascript-s3-raw-headers-unavailable', 'provider-fastly');
|
|
103
107
|
if (requirement === 'request' || ['response.json', 'response.text', 'response.custom'].includes(requirement)) {
|
|
104
108
|
return decision(requirement, 'eligible', 'fastly-request-response-lifecycle', 'provider-fastly');
|
|
105
109
|
}
|
package/src/provider-contract.js
CHANGED
|
@@ -13,9 +13,9 @@ 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', 'assets.lookup',
|
|
16
|
+
'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
|
-
'opaque.pass-through'
|
|
18
|
+
'opaque.pass-through', 's3.head', 's3.getText', 's3.putText'
|
|
19
19
|
],
|
|
20
20
|
lowering: {
|
|
21
21
|
request: 'fastly.compute.request',
|
|
@@ -27,12 +27,18 @@ const FASTLY_PROVIDER_DESCRIPTOR = canonical.normalizeDescriptor({
|
|
|
27
27
|
'secret.get': 'fastly.secret-store.get',
|
|
28
28
|
'kv.get': 'fastly.kv-store.get',
|
|
29
29
|
'kv.put': 'fastly.kv-store.put',
|
|
30
|
+
'kv.getVersioned': 'fastly.native.kv-store.getVersioned',
|
|
31
|
+
'kv.insertIfAbsent': 'fastly.native.kv-store.insertIfAbsent',
|
|
32
|
+
'kv.compareAndSwap': 'fastly.native.kv-store.compareAndSwap',
|
|
30
33
|
'assets.lookup': 'fastly.kv-store.assets.lookup',
|
|
31
34
|
'grip.channel': 'fastly.fanout.channel',
|
|
32
35
|
'grip.hold': 'fastly.fanout.hold',
|
|
33
36
|
'grip.publish': 'fastly.fanout.publish',
|
|
34
37
|
'grip.broadcast': 'fastly.grip.publish-control',
|
|
35
38
|
'jwt.verify': 'fastly.native.jwt.verify',
|
|
39
|
+
's3.head': 'fastly.native.s3.head',
|
|
40
|
+
's3.getText': 'fastly.native.s3.getText',
|
|
41
|
+
's3.putText': 'fastly.native.s3.putText',
|
|
36
42
|
'opaque.pass-through': 'fastly.response-body.stream'
|
|
37
43
|
}
|
|
38
44
|
});
|
|
@@ -53,6 +59,7 @@ function validateStaticBackendBindings(metadata = {}, bindings = {}) {
|
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
function createFastlyLoweringPlan(metadata, bindings = {}) {
|
|
62
|
+
require('./toolchain/s3.js').resolveFastlyS3(metadata.providerOperations || [], bindings.s3);
|
|
56
63
|
validateStaticBackendBindings(metadata, bindings);
|
|
57
64
|
return canonical.createProviderLoweringPlan(metadata, FASTLY_PROVIDER_DESCRIPTOR, {
|
|
58
65
|
...bindings,
|
|
@@ -337,6 +337,10 @@ function createFastlyProviderAdapter(baseOptions = {}) {
|
|
|
337
337
|
return Object.freeze({
|
|
338
338
|
id: 'fastly',
|
|
339
339
|
version: CANONICAL_FASTLY_RUNTIME_VERSION,
|
|
340
|
+
// This legacy JavaScript fixture surface has no Native KV host ABI. Keep
|
|
341
|
+
// that absence pre-dispatch/configuration, rather than pretending a failed
|
|
342
|
+
// fixture dispatch might have mutated storage. Real K3 evidence runs Wasm.
|
|
343
|
+
prepareConditionalKv() { return undefined; },
|
|
340
344
|
async dispatchEffect(effect, executionOptions = {}) {
|
|
341
345
|
const state = stateFor(executionOptions);
|
|
342
346
|
if (effect.kind === 'fetch') return dispatchFetch(effect, executionOptions);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Injected ABI authority for real generated Wasm evidence, never a service adapter.
|
|
4
|
+
// Raw persisted bytes and u64 generations remain independent of the Pulse codec.
|
|
5
|
+
function createConditionalKvAuthority() {
|
|
6
|
+
const stores = new Map(); let next = 1n;
|
|
7
|
+
return {
|
|
8
|
+
stores,
|
|
9
|
+
seed(store, key, wire, generation = next++) {
|
|
10
|
+
if (!stores.has(store)) stores.set(store, new Map());
|
|
11
|
+
stores.get(store).set(key, { wire: Buffer.from(wire), generation: BigInt(generation) });
|
|
12
|
+
},
|
|
13
|
+
get(store, key) { return stores.get(store)?.get(key); },
|
|
14
|
+
apply(store, key, wire, mode, condition) {
|
|
15
|
+
const values = stores.get(store), current = values.get(key);
|
|
16
|
+
if (mode === 1 && current || condition !== undefined && (!current || current.generation !== condition)) return 4;
|
|
17
|
+
while (next === current?.generation) next++;
|
|
18
|
+
this.seed(store, key, wire, next++); return 1;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function attachConditionalKvHost(imports, host, options) {
|
|
24
|
+
const { view, writeU32, writeU64, readBytes, readUtf8, alloc, bodies, bodyBytes, trace, now, advance } = host;
|
|
25
|
+
const authority = options.authority, stores = new Map(), pending = new Map(), bodyFixtures = new Map();
|
|
26
|
+
let sendingResponse = false;
|
|
27
|
+
const newResponse = imports.fastly_http_resp.new;
|
|
28
|
+
imports.fastly_http_resp.new = (...args) => { sendingResponse = true; return newResponse(...args); };
|
|
29
|
+
const call = (stage, detail = {}) => {
|
|
30
|
+
const fault = typeof options.onCall === 'function' ? options.onCall(stage, detail) || {} : {};
|
|
31
|
+
advance(Number(fault.delayMs || 0));
|
|
32
|
+
trace.push({ module: 'pulse_kv_evidence', name: stage, hostStatus: fault.status ?? 0, ...(fault.kvError === undefined ? {} : { kvError: fault.kvError }) });
|
|
33
|
+
return fault;
|
|
34
|
+
};
|
|
35
|
+
imports.fastly_kv_store = {
|
|
36
|
+
open(ptr, len, out) {
|
|
37
|
+
const name = readUtf8(ptr, len), fault = call('open', { store: name });
|
|
38
|
+
if (fault.status) return fault.status;
|
|
39
|
+
if (!authority.stores.has(name)) return 10;
|
|
40
|
+
const handle = alloc('kvStore'); stores.set(handle, name); writeU32(out, handle); return 0;
|
|
41
|
+
},
|
|
42
|
+
lookup(store, ptr, len, mask, config, out) {
|
|
43
|
+
const name = stores.get(store), key = readUtf8(ptr, len), fault = call('lookup', { store: name, key, mask, config: readBytes(config, 4) });
|
|
44
|
+
if (fault.status) return fault.status;
|
|
45
|
+
const item = fault.observation || authority.get(name, key);
|
|
46
|
+
const handle = alloc('kvLookup'); pending.set(handle, { kind: 'lookup', item, readyAt: now() + Number(fault.readyDelayMs || 0), fault });
|
|
47
|
+
writeU32(out, handle); return 0;
|
|
48
|
+
},
|
|
49
|
+
lookup_wait_v2(handle, body, meta, capacity, written, generation, error) {
|
|
50
|
+
const item = pending.get(handle); if (!item || item.readyAt > now()) throw new Error('KV lookup wait called before readiness.');
|
|
51
|
+
pending.delete(handle); const fault = call('lookup_wait_v2', { capacity, observation: item.item });
|
|
52
|
+
writeU32(error, fault.kvError ?? (item.item ? 1 : 3)); writeU64(generation, fault.generation ?? item.item?.generation ?? 0n);
|
|
53
|
+
writeU32(written, fault.metadataLength ?? 0);
|
|
54
|
+
if (fault.status) return fault.status;
|
|
55
|
+
if ((fault.kvError ?? (item.item ? 1 : 3)) !== 1) return 0;
|
|
56
|
+
const handleOut = alloc('body'); const bytes = Buffer.from(fault.wire || item.item.wire);
|
|
57
|
+
bodies.set(handleOut, { bytes, readOffset: 0, writes: [], readyAt: now() + Number(fault.bodyDelayMs || 0) });
|
|
58
|
+
bodyFixtures.set(handleOut, fault); writeU32(body, handleOut); return 0;
|
|
59
|
+
},
|
|
60
|
+
insert(store, ptr, len, body, mask, config, out) {
|
|
61
|
+
const name = stores.get(store), key = readUtf8(ptr, len), wire = Buffer.from(bodyBytes(body));
|
|
62
|
+
const mode = view().getUint32(config, true), condition = mask & 32 ? view().getBigUint64(config + 24, true) : undefined;
|
|
63
|
+
const fault = call('insert', { store: name, key, wire, mode, mask, condition, config: readBytes(config, 32) });
|
|
64
|
+
let code = fault.kvError;
|
|
65
|
+
if (code === undefined && (!fault.status || fault.commit)) code = authority.apply(name, key, wire, mode, condition);
|
|
66
|
+
bodies.delete(body); // Host takes ownership of the body.
|
|
67
|
+
if (fault.status) return fault.status;
|
|
68
|
+
const handle = alloc('kvInsert'); pending.set(handle, { kind: 'insert', code, readyAt: now() + Number(fault.readyDelayMs || 0) });
|
|
69
|
+
writeU32(out, handle); return 0;
|
|
70
|
+
},
|
|
71
|
+
insert_wait(handle, error) {
|
|
72
|
+
const item = pending.get(handle); if (!item || item.readyAt > now()) throw new Error('KV insert wait called before readiness.');
|
|
73
|
+
pending.delete(handle); const fault = call('insert_wait'); writeU32(error, fault.kvError ?? item.code ?? 0); return fault.status ?? 0;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const select = imports.fastly_async_io.select;
|
|
77
|
+
imports.fastly_async_io.select = (handles, count, timeout, done) => {
|
|
78
|
+
const handle = view().getUint32(handles, true), item = pending.get(handle) || (bodyFixtures.has(handle) && bodies.get(handle));
|
|
79
|
+
if (!item) return select(handles, count, timeout, done);
|
|
80
|
+
if (count !== 1 || timeout <= 0) throw new Error('KV select must be bounded.');
|
|
81
|
+
const fault = call('select', { kind: item.kind || 'body', timeout });
|
|
82
|
+
if (fault.status) return fault.status;
|
|
83
|
+
const wait = Math.max(0, item.readyAt - now()); advance(Math.min(wait, timeout));
|
|
84
|
+
writeU32(done, fault.readyIndex ?? (wait >= timeout ? 0xffffffff : 0)); return 0;
|
|
85
|
+
};
|
|
86
|
+
for (const name of ['new', 'write', 'read', 'close']) {
|
|
87
|
+
const original = imports.fastly_http_body[name];
|
|
88
|
+
imports.fastly_http_body[name] = (...args) => {
|
|
89
|
+
const [handle] = args;
|
|
90
|
+
if (sendingResponse) return original(...args);
|
|
91
|
+
if (name === 'read' && !bodyFixtures.has(handle)) return original(...args);
|
|
92
|
+
const fault = call('body_' + name, { handle });
|
|
93
|
+
if (fault.status) return fault.status;
|
|
94
|
+
const result = original(...args);
|
|
95
|
+
if (name === 'read') {
|
|
96
|
+
const fixture = bodyFixtures.get(handle), body = bodies.get(handle);
|
|
97
|
+
if (body) body.readyAt = now() + Number(fixture.chunkDelayMs || 0);
|
|
98
|
+
if (fault.written !== undefined) writeU32(args[3], fault.written);
|
|
99
|
+
}
|
|
100
|
+
if (name === 'write' && fault.written !== undefined) writeU32(args[4], fault.written);
|
|
101
|
+
if (name === 'close') bodyFixtures.delete(handle);
|
|
102
|
+
return result;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const clock = imports.wasi_snapshot_preview1.clock_time_get;
|
|
106
|
+
imports.wasi_snapshot_preview1.clock_time_get = (...args) => { const fault = call('clock'); return fault.status || clock(...args); };
|
|
107
|
+
return { pending, bodyFixtures };
|
|
108
|
+
}
|
|
109
|
+
module.exports = { createConditionalKvAuthority, attachConditionalKvHost };
|
|
@@ -10,7 +10,7 @@ const FASTLY_STATUS_BUFLEN = 4;
|
|
|
10
10
|
const FASTLY_STATUS_NONE = 10;
|
|
11
11
|
const FASTLY_KV_ERROR_OK = 1;
|
|
12
12
|
const FASTLY_KV_ERROR_NOT_FOUND = 3;
|
|
13
|
-
const SENSITIVE_HEADERS = new Set(['authorization', 'proxy-authorization', 'x-api-key', 'x-auth-token', 'cookie', 'set-cookie']);
|
|
13
|
+
const SENSITIVE_HEADERS = new Set(['authorization', 'proxy-authorization', 'x-api-key', 'x-auth-token', 'cookie', 'set-cookie', 'x-amz-security-token', 'x-amz-content-sha256']);
|
|
14
14
|
|
|
15
15
|
class FastlyNativePlatformCapabilitiesMockError extends Error {
|
|
16
16
|
constructor(message, code = 'PULSE_FASTLY_NATIVE_PLATFORM_CAPABILITIES_MOCK_FAILED', detail = {}) {
|
|
@@ -113,6 +113,8 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
113
113
|
const url = String(requestInput.url || `https://pulse.test${pathValue.startsWith('/') ? pathValue : `/${pathValue}`}`);
|
|
114
114
|
const requestHeaders = normalizeHeaders(requestInput.headers);
|
|
115
115
|
const trace = [];
|
|
116
|
+
let monotonicMs = 0;
|
|
117
|
+
const privateUrls = new Set();
|
|
116
118
|
let nextHandle = 10;
|
|
117
119
|
const typedHandleStarts = options.handleStarts && typeof options.handleStarts === 'object' ? options.handleStarts : {};
|
|
118
120
|
const typedHandleNext = new Map();
|
|
@@ -127,7 +129,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
127
129
|
const secretStores = normalizeNamedStores(options.secretStores, options.secretStore || 'app_secrets', options.secrets);
|
|
128
130
|
const secretValues = [...secretStores.values()].flatMap((values) => [...values.values()].map((value) => String(value))).filter(Boolean);
|
|
129
131
|
const redactText = (value) => secretValues.reduce((output, secret) => output.split(secret).join('[REDACTED]'), String(value));
|
|
130
|
-
const redactRuntimeHeaders = (headers) => headers.map(([name, value]) => [name, SENSITIVE_HEADERS.has(String(name).toLowerCase()) ? '[REDACTED]' : redactText(value)]);
|
|
132
|
+
const redactRuntimeHeaders = (headers, privateS3 = false) => headers.map(([name, value]) => [name, SENSITIVE_HEADERS.has(String(name).toLowerCase()) || privateS3 && ['etag', 'content-type'].includes(String(name).toLowerCase()) ? '[REDACTED]' : redactText(value)]);
|
|
131
133
|
const kvStores = normalizeNamedStores(options.kvStores, options.kvStore || 'app_sessions', undefined);
|
|
132
134
|
for (const values of kvStores.values()) for (const [key, value] of values) values.set(key, encodeKvValue(value));
|
|
133
135
|
const configHandles = new Map();
|
|
@@ -206,17 +208,37 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
206
208
|
headers,
|
|
207
209
|
origin: true
|
|
208
210
|
});
|
|
209
|
-
bodies.set(bodyHandle, { bytes: normalizeBody(body), readOffset: 0, writes: [] });
|
|
211
|
+
bodies.set(bodyHandle, { bytes: normalizeBody(body), readOffset: 0, writes: [], fixture: normalized, readyAt: monotonicMs + Number(normalized.bodyDelayMs || 0) });
|
|
210
212
|
return { responseHandle, bodyHandle };
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
function headerList(values, buffer, size, end, written) {
|
|
216
|
+
const output = Buffer.from(values.map((value) => String(value) + '\0').join(''));
|
|
217
|
+
writeU32(written, output.length); view().setBigInt64(Number(end), -1n, true);
|
|
218
|
+
if (output.length > Number(size)) return FASTLY_STATUS_BUFLEN;
|
|
219
|
+
new Uint8Array(memory().buffer, Number(buffer), output.length).set(output);
|
|
220
|
+
return FASTLY_STATUS_OK;
|
|
221
|
+
}
|
|
213
222
|
const imports = {
|
|
223
|
+
fastly_async_io: {
|
|
224
|
+
select(handles, count, timeout, done) {
|
|
225
|
+
if (Number(count) !== 1 || Number(timeout) <= 0) return FASTLY_STATUS_ERROR;
|
|
226
|
+
const handle = view().getUint32(Number(handles), true);
|
|
227
|
+
const item = pending.get(handle) || bodies.get(handle);
|
|
228
|
+
if (!item) return FASTLY_STATUS_BADF;
|
|
229
|
+
const wait = Math.max(0, Number(item.readyAt || 0) - monotonicMs);
|
|
230
|
+
monotonicMs += Math.min(wait, Number(timeout));
|
|
231
|
+
writeU32(done, wait >= Number(timeout) ? 0xffffffff : 0);
|
|
232
|
+
trace.push({ module: 'fastly_async_io', name: 'select', timeout: Number(timeout), timedOut: wait >= Number(timeout) });
|
|
233
|
+
return FASTLY_STATUS_OK;
|
|
234
|
+
}
|
|
235
|
+
},
|
|
214
236
|
wasi_snapshot_preview1: {
|
|
215
237
|
clock_time_get(clockId, precision, timeOut) {
|
|
216
238
|
const seconds = Number.isFinite(Number(options.clockUnixSeconds))
|
|
217
239
|
? Number(options.clockUnixSeconds)
|
|
218
240
|
: Math.floor(Date.now() / 1000);
|
|
219
|
-
writeU64(timeOut, BigInt(Math.trunc(seconds *
|
|
241
|
+
writeU64(timeOut, BigInt(Math.trunc((Number(clockId) === 1 ? monotonicMs : seconds * 1000 + monotonicMs) * 1_000_000)));
|
|
220
242
|
trace.push({
|
|
221
243
|
module: 'wasi_snapshot_preview1',
|
|
222
244
|
name: 'clock_time_get',
|
|
@@ -316,33 +338,54 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
316
338
|
if (!request) return FASTLY_STATUS_BADF;
|
|
317
339
|
const name = readUtf8(namePointer, nameLength);
|
|
318
340
|
const value = readUtf8(valuePointer, valueLength);
|
|
341
|
+
if (name.toLowerCase() === 'authorization' && value.includes('/s3/aws4_request')) privateUrls.add(request.url);
|
|
319
342
|
request.headers = request.headers.filter(([headerName]) => headerName.toLowerCase() !== name.toLowerCase());
|
|
320
343
|
request.headers.push([name, value]);
|
|
321
|
-
trace.push({ module: 'fastly_http_req', name: 'header_insert', handle: Number(handle), header: [name,
|
|
344
|
+
trace.push({ module: 'fastly_http_req', name: 'header_insert', handle: Number(handle), header: redactRuntimeHeaders([[name, value]], privateUrls.has(request.url))[0] });
|
|
322
345
|
return FASTLY_STATUS_OK;
|
|
323
346
|
},
|
|
347
|
+
cache_override_set(handle, tag, ttl, swr) {
|
|
348
|
+
const request = requests.get(Number(handle)); if (!request) return FASTLY_STATUS_BADF;
|
|
349
|
+
request.cacheOverride = Number(tag); return FASTLY_STATUS_OK;
|
|
350
|
+
},
|
|
351
|
+
auto_decompress_response_set(handle, encodings) {
|
|
352
|
+
const request = requests.get(Number(handle)); if (!request) return FASTLY_STATUS_BADF;
|
|
353
|
+
request.decompression = Number(encodings); return FASTLY_STATUS_OK;
|
|
354
|
+
},
|
|
355
|
+
close(handle) { return requests.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF; },
|
|
356
|
+
pending_req_poll_v2(handle, detail, done, responseOut, bodyOut) {
|
|
357
|
+
const item = pending.get(Number(handle)); if (!item) return FASTLY_STATUS_BADF;
|
|
358
|
+
if (monotonicMs < item.readyAt) { writeU32(done, 0); return FASTLY_STATUS_OK; }
|
|
359
|
+
writeU32(done, 1); return imports.fastly_http_req.pending_req_wait(handle, responseOut, bodyOut);
|
|
360
|
+
},
|
|
324
361
|
send_async(handle, bodyHandle, backendPointer, backendLength, pendingOut) {
|
|
325
362
|
const request = requests.get(Number(handle));
|
|
326
363
|
const body = bodies.get(Number(bodyHandle));
|
|
327
364
|
if (!request || !body) return FASTLY_STATUS_BADF;
|
|
328
365
|
const backend = readUtf8(backendPointer, backendLength);
|
|
366
|
+
const signedS3 = request.headers.some(([name, value]) => name.toLowerCase() === 'authorization' && value.startsWith('AWS4-HMAC-SHA256 '));
|
|
367
|
+
if (signedS3) privateUrls.add(request.url);
|
|
329
368
|
const fixture = resolveFixture(options.fixtures, backend, request.method, request.url);
|
|
330
369
|
if (!fixture) {
|
|
331
|
-
trace.push({ module: 'fastly_http_req', name: 'send_async', handle: Number(handle), backend, method: request.method, url: request.url, missingFixture: true });
|
|
370
|
+
trace.push({ module: 'fastly_http_req', name: 'send_async', handle: Number(handle), backend, method: request.method, url: signedS3 ? '[REDACTED]' : request.url, missingFixture: true });
|
|
332
371
|
return FASTLY_STATUS_ERROR;
|
|
333
372
|
}
|
|
334
373
|
const pendingHandle = alloc('pending');
|
|
335
374
|
const capturedBody = bodyBytes(bodyHandle);
|
|
336
|
-
pending.set(pendingHandle, { request: { ...request, headers: request.headers.map((entry) => [...entry]), body: capturedBody }, backend, fixture });
|
|
375
|
+
pending.set(pendingHandle, { request: { ...request, headers: request.headers.map((entry) => [...entry]), body: capturedBody }, backend, fixture, readyAt: monotonicMs + Number(fixture.delayMs || 0) });
|
|
376
|
+
if (typeof options.onOutboundRequest === 'function') options.onOutboundRequest({ ...request, backend, headers: request.headers.map((entry) => [...entry]), body: capturedBody });
|
|
377
|
+
if (fixture.sendStatus) { pending.delete(pendingHandle); return Number(fixture.sendStatus); }
|
|
378
|
+
if (request.headers.some(([name, value]) => name.toLowerCase() === 'authorization' && value.startsWith('AWS4-HMAC-SHA256 '))) privateUrls.add(request.url);
|
|
379
|
+
requests.delete(Number(handle)); bodies.delete(Number(bodyHandle));
|
|
337
380
|
outboundRequests.push(Object.freeze({
|
|
338
381
|
backend,
|
|
339
382
|
method: request.method,
|
|
340
|
-
url: redactText(request.url),
|
|
341
|
-
headers: Object.freeze(redactRuntimeHeaders(request.headers).map((header) => Object.freeze(header))),
|
|
342
|
-
body: redactText(capturedBody.toString('utf8'))
|
|
383
|
+
url: privateUrls.has(request.url) ? '[REDACTED]' : redactText(request.url),
|
|
384
|
+
headers: Object.freeze(redactRuntimeHeaders(request.headers, signedS3).map((header) => Object.freeze(header))),
|
|
385
|
+
body: signedS3 ? '[REDACTED]' : redactText(capturedBody.toString('utf8'))
|
|
343
386
|
}));
|
|
344
387
|
writeU32(pendingOut, pendingHandle);
|
|
345
|
-
trace.push({ module: 'fastly_http_req', name: 'send_async', handle: Number(handle), bodyHandle: Number(bodyHandle), pendingHandle, backend, method: request.method, url: redactText(request.url), headers: redactRuntimeHeaders(request.headers), bodyBytes: capturedBody.length });
|
|
388
|
+
trace.push({ module: 'fastly_http_req', name: 'send_async', handle: Number(handle), bodyHandle: Number(bodyHandle), pendingHandle, backend, method: request.method, url: redactText(request.url), headers: redactRuntimeHeaders(request.headers, signedS3), bodyBytes: capturedBody.length });
|
|
346
389
|
return FASTLY_STATUS_OK;
|
|
347
390
|
},
|
|
348
391
|
pending_req_wait(handle, responseOut, bodyOut) {
|
|
@@ -384,6 +427,8 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
384
427
|
const handle = alloc('secretStore'); secretStoreHandles.set(handle, values); writeU32(handleOut, handle); return FASTLY_STATUS_OK;
|
|
385
428
|
},
|
|
386
429
|
get(handle, keyPointer, keyLength, secretOut) {
|
|
430
|
+
monotonicMs += Number(options.secretDelayMs || 0);
|
|
431
|
+
if (typeof options.onSecretLookup === 'function') options.onSecretLookup();
|
|
387
432
|
const values = secretStoreHandles.get(Number(handle));
|
|
388
433
|
const key = readUtf8(keyPointer, keyLength);
|
|
389
434
|
const value = values && values.get(key);
|
|
@@ -447,6 +492,16 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
447
492
|
}
|
|
448
493
|
},
|
|
449
494
|
fastly_http_resp: {
|
|
495
|
+
close(handle) { trace.push({ module: 'fastly_http_resp', name: 'close' }); return responses.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF; },
|
|
496
|
+
header_names_get(handle, buffer, size, cursor, end, written) {
|
|
497
|
+
const response = responses.get(Number(handle)); if (!response || cursor !== 0) return FASTLY_STATUS_BADF;
|
|
498
|
+
return headerList([...new Set(response.headers.map(([name]) => name.toLowerCase()))], buffer, size, end, written);
|
|
499
|
+
},
|
|
500
|
+
header_values_get(handle, pointer, length, buffer, size, cursor, end, written) {
|
|
501
|
+
const response = responses.get(Number(handle)); if (!response || cursor !== 0) return FASTLY_STATUS_BADF;
|
|
502
|
+
const name = readUtf8(pointer, length).toLowerCase();
|
|
503
|
+
return headerList(response.headers.filter(([key]) => key.toLowerCase() === name).map(([, value]) => value), buffer, size, end, written);
|
|
504
|
+
},
|
|
450
505
|
new(handleOut) {
|
|
451
506
|
const handle = alloc('response');
|
|
452
507
|
responses.set(handle, { status: 200, headers: [], origin: false });
|
|
@@ -503,6 +558,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
503
558
|
}
|
|
504
559
|
},
|
|
505
560
|
fastly_http_body: {
|
|
561
|
+
close(handle) { trace.push({ module: 'fastly_http_body', name: 'close' }); return bodies.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF; },
|
|
506
562
|
new(handleOut) {
|
|
507
563
|
const handle = alloc('body');
|
|
508
564
|
bodies.set(handle, { bytes: Buffer.alloc(0), readOffset: 0, writes: [] });
|
|
@@ -513,11 +569,13 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
513
569
|
read(handle, buffer, bufferLength, readOut) {
|
|
514
570
|
const body = bodies.get(Number(handle));
|
|
515
571
|
if (!body) return FASTLY_STATUS_BADF;
|
|
572
|
+
if (body.fixture && body.fixture.bodyReadStatus) return Number(body.fixture.bodyReadStatus);
|
|
516
573
|
const source = bodyBytes(handle);
|
|
517
574
|
const offset = body.readOffset || 0;
|
|
518
|
-
const count = Math.min(Number(bufferLength), Math.max(0, source.length - offset));
|
|
575
|
+
const count = Math.min(Number(bufferLength), Math.max(0, source.length - offset), Number(body.fixture && body.fixture.chunkBytes || 65536));
|
|
519
576
|
if (count > 0) new Uint8Array(memory().buffer, Number(buffer), count).set(source.subarray(offset, offset + count));
|
|
520
577
|
body.readOffset = offset + count;
|
|
578
|
+
body.readyAt = monotonicMs + Number(body.fixture && body.fixture.chunkDelayMs || 0);
|
|
521
579
|
writeU32(readOut, count);
|
|
522
580
|
trace.push({ module: 'fastly_http_body', name: 'read', handle: Number(handle), bytes: count });
|
|
523
581
|
return FASTLY_STATUS_OK;
|
|
@@ -525,7 +583,9 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
525
583
|
write(handle, buffer, bufferLength, end, writtenOut) {
|
|
526
584
|
const body = bodies.get(Number(handle));
|
|
527
585
|
if (!body) return FASTLY_STATUS_BADF;
|
|
528
|
-
|
|
586
|
+
if (requests.size > 1 && options.outboundBodyWriteStatus) return Number(options.outboundBodyWriteStatus);
|
|
587
|
+
const length = Math.min(Number(bufferLength), Number(options.bodyWriteChunkBytes || bufferLength));
|
|
588
|
+
const bytes = readBytes(buffer, length);
|
|
529
589
|
body.writes.push(bytes);
|
|
530
590
|
writeU32(writtenOut, bytes.length);
|
|
531
591
|
trace.push({ module: 'fastly_http_body', name: 'write', handle: Number(handle), bytes: bytes.length, end: Number(end) });
|
|
@@ -534,12 +594,28 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
534
594
|
}
|
|
535
595
|
};
|
|
536
596
|
|
|
597
|
+
const kvEvidence = options.conditionalKv ? require('./conditional-kv-host.js').attachConditionalKvHost(imports, {
|
|
598
|
+
view, writeU32, writeU64, readBytes, readUtf8, alloc, bodies, bodyBytes, trace,
|
|
599
|
+
now: () => monotonicMs, advance: ms => { monotonicMs += ms; }
|
|
600
|
+
}, options.conditionalKv) : undefined;
|
|
601
|
+
|
|
602
|
+
// Model invocation termination, including cancellation raised by a host call.
|
|
603
|
+
// Throwing stops the Wasm invocation; it never fabricates an effect result.
|
|
604
|
+
const checkActive = () => { if (options.signal && options.signal.aborted) throw options.signal.reason || new Error('Request cancelled.'); };
|
|
605
|
+
checkActive();
|
|
606
|
+
if (options.signal) for (const namespace of Object.values(imports)) {
|
|
607
|
+
for (const [name, hostcall] of Object.entries(namespace)) if (typeof hostcall === 'function') namespace[name] = (...args) => {
|
|
608
|
+
checkActive(); const result = hostcall(...args); checkActive(); return result;
|
|
609
|
+
};
|
|
610
|
+
}
|
|
537
611
|
const module = new WebAssembly.Module(wasm);
|
|
538
612
|
instance = new WebAssembly.Instance(module, imports);
|
|
539
613
|
if (typeof instance.exports._start !== 'function') {
|
|
540
614
|
throw new FastlyNativePlatformCapabilitiesMockError('Pass98 native module is missing _start.', 'PULSE_FASTLY_NATIVE_PLATFORM_CAPABILITIES_MOCK_START_MISSING');
|
|
541
615
|
}
|
|
616
|
+
if (options.conditionalKv && options.conditionalKv.deadlineNs !== undefined) instance.exports.pulse_fastly_kv_request_deadline(BigInt(options.conditionalKv.deadlineNs));
|
|
542
617
|
instance.exports._start();
|
|
618
|
+
for (const entry of trace) if (entry.url && privateUrls.has(entry.url)) entry.url = '[REDACTED]';
|
|
543
619
|
const lastError = typeof instance.exports.pulse_fastly_last_error === 'function'
|
|
544
620
|
? Number(instance.exports.pulse_fastly_last_error())
|
|
545
621
|
: undefined;
|
|
@@ -580,6 +656,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
580
656
|
logs: Object.freeze(logs),
|
|
581
657
|
outboundRequests: Object.freeze(outboundRequests),
|
|
582
658
|
stores: Object.freeze({ config: snapshotStores(configStores), secrets: Object.fromEntries([...secretStores].map(([name, values]) => [name, Object.fromEntries([...values].map(([key]) => [key, '[REDACTED]']))])), kv: snapshotStores(kvStores, true) }),
|
|
659
|
+
kvEvidence,
|
|
583
660
|
instance
|
|
584
661
|
});
|
|
585
662
|
}
|
package/src/toolchain/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
const { normalizeFastlyS3 } = require('./s3.js');
|
|
2
3
|
|
|
3
4
|
function providerConfigError(code, message, detail) {
|
|
4
5
|
const error = new TypeError(message);
|
|
@@ -37,6 +38,7 @@ function normalizeFastlyProviderConfig(value) {
|
|
|
37
38
|
configStore: String(bindings.configStore || 'pulse_config'),
|
|
38
39
|
secretStore: String(bindings.secretStore || 'pulse_secrets'),
|
|
39
40
|
kv: normalizeStringMap(bindings.kv, 'PULSE_FASTLY_KV_BINDINGS_INVALID'),
|
|
41
|
+
s3: normalizeFastlyS3(bindings.s3),
|
|
40
42
|
backends: normalizeStringMap(bindings.backends, 'PULSE_FASTLY_BACKEND_BINDINGS_INVALID'),
|
|
41
43
|
grip: Object.freeze({
|
|
42
44
|
fanoutBackend: gripInput.fanoutBackend === undefined ? undefined : String(gripInput.fanoutBackend),
|
|
@@ -98,6 +100,7 @@ const FASTLY_PROJECT_CONFIG_REFERENCE = Object.freeze({
|
|
|
98
100
|
})
|
|
99
101
|
]),
|
|
100
102
|
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.' }),
|
|
101
104
|
Object.freeze({ section: 'fastly', path: 'fastly.configStore', type: 'string', default: '`pulse_config`', scope: 'Fastly config capability', description: 'Fastly Config Store resource name.' }),
|
|
102
105
|
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.' }),
|
|
103
106
|
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
|
@@ -169,6 +169,8 @@ function createDriver() {
|
|
|
169
169
|
runtimeClass: 'native',
|
|
170
170
|
status: 'supported',
|
|
171
171
|
capabilities: Object.freeze([
|
|
172
|
+
's3.head', 's3.getText',
|
|
173
|
+
'kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap',
|
|
172
174
|
'jwt.verify',
|
|
173
175
|
'jwt.verify.hs256',
|
|
174
176
|
'jwt.verify.es256',
|
|
@@ -221,7 +223,7 @@ function createDriver() {
|
|
|
221
223
|
automaticFallback: false,
|
|
222
224
|
crypto: defineCryptoTargetCapabilities({
|
|
223
225
|
target: 'native',
|
|
224
|
-
algorithms: [{
|
|
226
|
+
algorithms: [...['SHA-256', 'HMAC-SHA256'].map((algorithm) => ({ algorithm, realization: 'guest-source:pulse-hmac-as', implemented: true, status: 'implemented-o2' })), {
|
|
225
227
|
algorithm: 'HS256',
|
|
226
228
|
realization: 'guest-source:pulse-hmac-as',
|
|
227
229
|
implemented: true,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { normalizeBinding, namePattern } = require('@pulse-compute/s3/provider');
|
|
3
|
+
function normalizeFastlyS3(input = {}) {
|
|
4
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || ![Object.prototype, null].includes(Object.getPrototypeOf(input))) throw new TypeError('Fastly S3 bindings must be a map.');
|
|
5
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
6
|
+
const field = Object.getOwnPropertyDescriptor(input, key);
|
|
7
|
+
if (typeof key !== 'string' || !field.enumerable || !Object.hasOwn(field, 'value')) throw new TypeError('Fastly S3 bindings require data properties.');
|
|
8
|
+
}
|
|
9
|
+
return Object.freeze(Object.fromEntries(Object.entries(input).map(([name, value]) => {
|
|
10
|
+
if (!namePattern.test(name) || !value || typeof value !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
11
|
+
|| Reflect.ownKeys(value).some((key) => { const field = Object.getOwnPropertyDescriptor(value, key); return typeof key !== 'string' || !field.enumerable || !Object.hasOwn(field, 'value'); })) throw new TypeError('Fastly S3 binding is invalid.');
|
|
12
|
+
const { backend, ...binding } = value;
|
|
13
|
+
if (typeof backend !== 'string' || !namePattern.test(backend)) throw new TypeError('Fastly S3 requires a named static backend.');
|
|
14
|
+
return [name, Object.freeze({ ...normalizeBinding(binding), backend })];
|
|
15
|
+
})));
|
|
16
|
+
}
|
|
17
|
+
function resolveFastlyS3(effects, input) {
|
|
18
|
+
const configured = normalizeFastlyS3(input);
|
|
19
|
+
return Object.freeze(effects.flatMap((effect, index) => {
|
|
20
|
+
if (!['s3.head', 's3.getText', 's3.putText'].includes(effect.kind)) return [];
|
|
21
|
+
const name = effect.resource && effect.resource.binding;
|
|
22
|
+
if (!Object.hasOwn(configured, name)) throw new TypeError('The required Fastly S3 binding is missing.');
|
|
23
|
+
return [Object.freeze({ index, ...configured[name], contentType: effect.operation === 'putText' ? require('@pulse-compute/s3/provider').normalizePutOptions({ contentType: effect.payload && effect.payload.contentType }).contentType : '' })];
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
module.exports = { normalizeFastlyS3, resolveFastlyS3 };
|