@pulse-compute/provider-node 1.0.0-beta.4 → 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.
- package/README.md +11 -9
- package/package.json +14 -10
- package/src/capabilities.js +19 -3
- package/src/config/s3.js +1 -1
- package/src/javascript/fetch-adapter.js +16 -1
- package/src/javascript/incoming-body.js +163 -0
- package/src/javascript/jwt-verifier.js +14 -4
- package/src/javascript/lifecycle.js +4 -2
- package/src/javascript/node-adapter.js +170 -118
- package/src/javascript/package-effects.js +5 -2
- package/src/javascript/runtime-host.js +13 -3
- package/src/javascript/s3.js +4 -3
- package/src/javascript/target-support-policy.js +17 -4
- package/src/javascript/target.js +5 -0
- package/src/javascript/test-runtime.js +9 -1
- package/src/native/target.js +25 -0
- package/src/runtime/body-transform.js +87 -0
- package/src/runtime/canonical-api-runtime.js +53 -11
- package/src/runtime/generated-output-test.js +35 -0
- package/src/runtime/generated-output.js +167 -0
- package/src/runtime/incoming-body.js +63 -0
- package/src/runtime/jwt-verifier.js +26 -5
- package/src/runtime/s3-reader.js +76 -12
- package/src/server/build.js +53 -0
- package/src/server/config.js +54 -0
- package/src/server/index.d.ts +53 -0
- package/src/server/index.js +146 -0
- package/src/toolchain.js +24 -6
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createIncomingBodyOwnership, PulseRuntimeContractError } = require('@pulse-compute/runtime/host');
|
|
4
|
+
const limits = Object.freeze({ inputBytes: 65536, outputBytes: 262144, expansion: 4, sourceChunkBytes: 65536, readBytes: 4093, textBytes: 4096, reads: 18 });
|
|
5
|
+
const error = (code, message) => new PulseRuntimeContractError(code, message);
|
|
6
|
+
|
|
7
|
+
function normalizeBodyTransform(value, options) {
|
|
8
|
+
if (value === undefined) return undefined;
|
|
9
|
+
if (value !== true || options.generatedOutput !== true || options.bodyForwarding !== undefined
|
|
10
|
+
|| !Number.isInteger(options.maxDurationMs) || options.maxDurationMs < 1 || options.maxDurationMs > 30000) {
|
|
11
|
+
throw error('PULSE_TRANSFORM_CONFIG_INVALID', 'node.bodyTransform requires generatedOutput and maxDurationMs (1–30000), and excludes bodyForwarding.');
|
|
12
|
+
}
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createTransformInput(request, options) {
|
|
17
|
+
try { return admitTransformInput(request, options); }
|
|
18
|
+
catch (reason) {
|
|
19
|
+
if (request.body && !request.body.locked) void request.body.cancel(reason).catch(() => {});
|
|
20
|
+
throw reason;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function admitTransformInput(request, options) {
|
|
25
|
+
normalizeBodyTransform(options.bodyTransform, options);
|
|
26
|
+
const length = request.headers.get('content-length');
|
|
27
|
+
if (length !== null && (!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length)))) throw error('PULSE_TRANSFORM_HEADERS_INVALID', 'Invalid incoming content length.');
|
|
28
|
+
if (length !== null && Number(length) > limits.inputBytes) throw error('PULSE_TRANSFORM_INPUT_LIMIT', 'Transform input exceeds 65536 bytes.');
|
|
29
|
+
// Fatal decoding preserves a BOM as data and carries at most three UTF-8 bytes.
|
|
30
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
31
|
+
let reader, pending, offset = 0, ended = false, stopped = false, failure, reads = 0;
|
|
32
|
+
const metrics = { inputBytes: 0, deliveredBytes: 0, maxSourceChunkBytes: 0, maxTextBytes: 0, maxQueuedBytes: 0 };
|
|
33
|
+
function release() { try { reader?.releaseLock(); } catch (_) {} }
|
|
34
|
+
function cancel(reason) {
|
|
35
|
+
if (stopped) return;
|
|
36
|
+
stopped = true; failure ||= reason; pending = undefined;
|
|
37
|
+
try { Promise.resolve(reader ? reader.cancel(reason) : request.body?.cancel(reason)).catch(() => {}).finally(release); } catch (_) {}
|
|
38
|
+
}
|
|
39
|
+
return createIncomingBodyOwnership({
|
|
40
|
+
validate() { throw error('PULSE_REQUEST_BODY_OWNERSHIP', 'Transform input cannot be forwarded.'); },
|
|
41
|
+
forward() { throw error('PULSE_REQUEST_BODY_OWNERSHIP', 'Transform input cannot be forwarded.'); },
|
|
42
|
+
cancel,
|
|
43
|
+
async readTextChunk(budget) {
|
|
44
|
+
if (stopped) throw failure || error('PULSE_TRANSFORM_CLOSED', 'Transform input is closed.');
|
|
45
|
+
if (++reads > limits.reads) throw error('PULSE_TRANSFORM_READ_LIMIT', 'Transform input permits at most 18 reads, including EOF.');
|
|
46
|
+
const removeAbort = budget.onAbort(() => cancel(budget.signal.reason));
|
|
47
|
+
try {
|
|
48
|
+
budget.check();
|
|
49
|
+
// Fixed bounded blocks make chunk transforms independent of network fragmentation.
|
|
50
|
+
const block = new Uint8Array(limits.readBytes);
|
|
51
|
+
let used = 0;
|
|
52
|
+
while (used < block.length && !ended) {
|
|
53
|
+
if (!pending) {
|
|
54
|
+
if (!request.body) { ended = true; break; }
|
|
55
|
+
reader ||= request.body.getReader();
|
|
56
|
+
const next = await budget.race(reader.read());
|
|
57
|
+
budget.check();
|
|
58
|
+
if (next.done) { ended = true; release(); break; }
|
|
59
|
+
if (!(next.value instanceof Uint8Array) || next.value.byteLength > limits.sourceChunkBytes || next.value.buffer.byteLength > limits.sourceChunkBytes) {
|
|
60
|
+
throw error('PULSE_TRANSFORM_CHUNK_INVALID', 'Transform source chunks and backing allocations must not exceed 65536 bytes.');
|
|
61
|
+
}
|
|
62
|
+
metrics.inputBytes += next.value.byteLength;
|
|
63
|
+
if (metrics.inputBytes > limits.inputBytes) throw error('PULSE_TRANSFORM_INPUT_LIMIT', 'Transform input exceeds 65536 bytes.');
|
|
64
|
+
pending = next.value; offset = 0;
|
|
65
|
+
metrics.maxSourceChunkBytes = Math.max(metrics.maxSourceChunkBytes, pending.byteLength);
|
|
66
|
+
metrics.maxQueuedBytes = Math.max(metrics.maxQueuedBytes, pending.byteLength + block.length + 3);
|
|
67
|
+
}
|
|
68
|
+
const count = Math.min(block.length - used, pending.byteLength - offset);
|
|
69
|
+
block.set(pending.subarray(offset, offset + count), used);
|
|
70
|
+
used += count; offset += count;
|
|
71
|
+
if (offset === pending.byteLength) pending = undefined;
|
|
72
|
+
}
|
|
73
|
+
let text;
|
|
74
|
+
try { text = decoder.decode(block.subarray(0, used), { stream: !ended }); }
|
|
75
|
+
catch (_) { throw error('PULSE_TRANSFORM_UTF8_INVALID', 'Transform input must be valid UTF-8, including its final sequence.'); }
|
|
76
|
+
const bytes = Buffer.byteLength(text);
|
|
77
|
+
metrics.deliveredBytes += bytes;
|
|
78
|
+
metrics.maxTextBytes = Math.max(metrics.maxTextBytes, bytes);
|
|
79
|
+
options.onBodyTransformObservation?.(Object.freeze({ ...metrics, reads }));
|
|
80
|
+
return Object.freeze({ done: ended && used === 0 && text.length === 0, text });
|
|
81
|
+
} catch (reason) { cancel(reason); throw reason; }
|
|
82
|
+
finally { removeAbort(); }
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { limits, normalizeBodyTransform, createTransformInput };
|
|
@@ -45,11 +45,13 @@ function providerResponseSpec(spec = {}) {
|
|
|
45
45
|
streamRef: spec.streamRef
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
-
const
|
|
48
|
+
const textFixture = Object.prototype.hasOwnProperty.call(spec, 'text') && !Object.prototype.hasOwnProperty.call(spec, 'value');
|
|
49
|
+
const body = Object.prototype.hasOwnProperty.call(spec, 'body') ? spec.body
|
|
50
|
+
: textFixture ? String(spec.text) : JSON.stringify(Object.prototype.hasOwnProperty.call(spec, 'value') ? spec.value : {});
|
|
49
51
|
return {
|
|
50
52
|
status: Number(Object.prototype.hasOwnProperty.call(spec, 'status') ? spec.status : 200),
|
|
51
53
|
kind: spec.kind || 'text',
|
|
52
|
-
headers: normalizeHeaders(spec.headers || [['content-type', 'application/json; charset=utf-8']]),
|
|
54
|
+
headers: normalizeHeaders(spec.headers || [['content-type', textFixture ? 'text/plain; charset=utf-8' : 'application/json; charset=utf-8']]),
|
|
53
55
|
body: typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)
|
|
54
56
|
};
|
|
55
57
|
}
|
|
@@ -134,7 +136,12 @@ function assetContentType(key) {
|
|
|
134
136
|
})[extension] || 'application/octet-stream';
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
function assetResponse(payload, stores) {
|
|
139
|
+
function assetResponse(payload, stores, request) {
|
|
140
|
+
if (payload.embeddedId !== undefined) {
|
|
141
|
+
const result = require('@pulse-compute/wasm-contracts/assets/contracts').embeddedAssetResponse(payload, request?.headers);
|
|
142
|
+
const bodyStream = result.bytes === null ? undefined : new ReadableStream({start(controller) { controller.enqueue(result.bytes); controller.close(); }});
|
|
143
|
+
return {status:result.status,kind:'stream',headers:[...result.headers],...(bodyStream ? {bodyStream} : {})};
|
|
144
|
+
}
|
|
138
145
|
const storeName = portableRuntimeHost.normalizeKvNamespace(payload.store, {});
|
|
139
146
|
const key = portableRuntimeHost.normalizeKvKey(payload.key, {});
|
|
140
147
|
const store = stores.get(storeName);
|
|
@@ -198,7 +205,7 @@ function responseIsStructured(method, headers, status = 200) {
|
|
|
198
205
|
|| contentType.includes('application/x-www-form-urlencoded');
|
|
199
206
|
}
|
|
200
207
|
|
|
201
|
-
async function readStructuredResponseText(response, maxBytes) {
|
|
208
|
+
async function readStructuredResponseText(response, maxBytes, signal) {
|
|
202
209
|
const limit = Number.isSafeInteger(Number(maxBytes)) && Number(maxBytes) > 0 ? Number(maxBytes) : 65_536;
|
|
203
210
|
const declared = Number(response.headers && response.headers.get && response.headers.get('content-length'));
|
|
204
211
|
if (Number.isSafeInteger(declared) && declared > limit) {
|
|
@@ -206,11 +213,16 @@ async function readStructuredResponseText(response, maxBytes) {
|
|
|
206
213
|
}
|
|
207
214
|
if (!response.body) return '';
|
|
208
215
|
const reader = response.body.getReader();
|
|
216
|
+
const cancel = () => { Promise.resolve(reader.cancel(signal.reason)).catch(() => {}); };
|
|
217
|
+
if (signal?.aborted) cancel();
|
|
218
|
+
else signal?.addEventListener('abort', cancel, { once: true });
|
|
209
219
|
const chunks = [];
|
|
210
220
|
let bytes = 0;
|
|
211
221
|
try {
|
|
212
222
|
while (true) {
|
|
223
|
+
signal?.throwIfAborted();
|
|
213
224
|
const result = await reader.read();
|
|
225
|
+
signal?.throwIfAborted();
|
|
214
226
|
if (result.done) break;
|
|
215
227
|
const chunk = result.value instanceof Uint8Array ? result.value : new Uint8Array(result.value || 0);
|
|
216
228
|
bytes += chunk.byteLength;
|
|
@@ -221,6 +233,7 @@ async function readStructuredResponseText(response, maxBytes) {
|
|
|
221
233
|
chunks.push(chunk);
|
|
222
234
|
}
|
|
223
235
|
} finally {
|
|
236
|
+
signal?.removeEventListener('abort', cancel);
|
|
224
237
|
try { reader.releaseLock(); } catch (_) { /* already released */ }
|
|
225
238
|
}
|
|
226
239
|
const output = new Uint8Array(bytes);
|
|
@@ -234,9 +247,13 @@ async function readStructuredResponseText(response, maxBytes) {
|
|
|
234
247
|
|
|
235
248
|
async function executeLiveFetch(normalized, fetchImplementation, options = {}) {
|
|
236
249
|
const timeoutMs = Number(normalized.init.timeoutMs);
|
|
237
|
-
const controller =
|
|
238
|
-
const
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
const cancel = () => controller.abort(options.signal.reason);
|
|
252
|
+
if (options.signal?.aborted) cancel();
|
|
253
|
+
else options.signal?.addEventListener('abort', cancel, { once: true });
|
|
254
|
+
const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? setTimeout(() => controller.abort(new Error(`Fetch timed out after ${timeoutMs}ms.`)), timeoutMs) : undefined;
|
|
239
255
|
try {
|
|
256
|
+
controller.signal.throwIfAborted();
|
|
240
257
|
const response = await fetchImplementation(normalized.parts.url, {
|
|
241
258
|
method: normalized.init.method,
|
|
242
259
|
headers: normalized.init.headers,
|
|
@@ -244,6 +261,10 @@ async function executeLiveFetch(normalized, fetchImplementation, options = {}) {
|
|
|
244
261
|
signal: controller && controller.signal,
|
|
245
262
|
redirect: 'follow'
|
|
246
263
|
});
|
|
264
|
+
if (controller.signal.aborted) {
|
|
265
|
+
if (response.body) void response.body.cancel(controller.signal.reason).catch(() => {});
|
|
266
|
+
throw controller.signal.reason;
|
|
267
|
+
}
|
|
247
268
|
const headers = responseHeaders(response);
|
|
248
269
|
const responseMode = normalized.responseMode || 'auto';
|
|
249
270
|
const structured = responseMode === 'structured'
|
|
@@ -251,12 +272,22 @@ async function executeLiveFetch(normalized, fetchImplementation, options = {}) {
|
|
|
251
272
|
if (structured) {
|
|
252
273
|
const body = normalized.init.method === 'HEAD' || !responseStatusAllowsBody(response.status)
|
|
253
274
|
? ''
|
|
254
|
-
: await readStructuredResponseText(response, options.maxFetchBodyBytes || options.maxBodyBytes);
|
|
275
|
+
: await readStructuredResponseText(response, options.maxFetchBodyBytes || options.maxBodyBytes, controller.signal);
|
|
255
276
|
return { status: response.status, kind: 'text', headers, body };
|
|
256
277
|
}
|
|
257
278
|
const bodyStream = response.body && typeof Readable.fromWeb === 'function' ? Readable.fromWeb(response.body) : response.body;
|
|
279
|
+
if (bodyStream && options.requestBudget) {
|
|
280
|
+
// Until normal handoff, the request still owns an opaque result. A late
|
|
281
|
+
// response after expiry must be disposed without an unhandled error.
|
|
282
|
+
if (typeof bodyStream.on === 'function') bodyStream.on('error', () => {});
|
|
283
|
+
options.requestBudget.onAbort(() => {
|
|
284
|
+
if (typeof bodyStream.destroy === 'function') bodyStream.destroy(options.requestBudget.signal.reason);
|
|
285
|
+
else if (!bodyStream.locked) void bodyStream.cancel(options.requestBudget.signal.reason).catch(() => {});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
258
288
|
return { status: response.status, kind: 'stream', headers, bodyStream };
|
|
259
289
|
} catch (error) {
|
|
290
|
+
options.signal?.throwIfAborted();
|
|
260
291
|
if ((controller && controller.signal.aborted) || error && error.name === 'AbortError') {
|
|
261
292
|
throw new hostRuntime.CanonicalRuntimeError('FetchTimeoutError', 'PULSE_FETCH_TIMEOUT', 'Live Node fetch exceeded the configured timeout.', { effectId: normalized.id, url: normalized.parts.url, timeoutMs });
|
|
262
293
|
}
|
|
@@ -264,6 +295,7 @@ async function executeLiveFetch(normalized, fetchImplementation, options = {}) {
|
|
|
264
295
|
throw new hostRuntime.CanonicalRuntimeError('FetchNetworkError', 'PULSE_FETCH_NETWORK', `Live Node fetch failed for ${normalized.init.method} ${normalized.parts.url}.`, { effectId: normalized.id, url: normalized.parts.url, message: error && error.message ? error.message : String(error) });
|
|
265
296
|
} finally {
|
|
266
297
|
if (timeout) clearTimeout(timeout);
|
|
298
|
+
options.signal?.removeEventListener('abort', cancel);
|
|
267
299
|
}
|
|
268
300
|
}
|
|
269
301
|
|
|
@@ -461,6 +493,11 @@ function createNodeProviderAdapter(baseOptions = {}) {
|
|
|
461
493
|
function kvStoresFor(executionOptions) { return kvReferenceFor(executionOptions).legacyStores(); }
|
|
462
494
|
|
|
463
495
|
async function dispatchFetch(normalized, executionOptions = {}) {
|
|
496
|
+
if (normalized.init.bodyMode === 'incoming-request-v1') {
|
|
497
|
+
const response = await portableRuntimeHost.forwardIncomingBody(normalized.init.body, normalized.parts.url, normalized.init, executionOptions);
|
|
498
|
+
if (response.body) executionOptions.forwardedBodies?.add(response.body);
|
|
499
|
+
return { status: response.status, kind: 'stream', headers: responseHeaders(response), bodyStream: response.body };
|
|
500
|
+
}
|
|
464
501
|
const fetchFixtures = normalizeFetchFixtures(executionOptions.fetches || baseOptions.fetches);
|
|
465
502
|
const providerData = buildProviderBackends(fetchFixtures);
|
|
466
503
|
const fixture = providerData.specs.get(`${normalized.init.method} ${normalized.parts.url}`) || providerData.specs.get(normalized.parts.url);
|
|
@@ -501,14 +538,19 @@ function createNodeProviderAdapter(baseOptions = {}) {
|
|
|
501
538
|
},
|
|
502
539
|
dispatchFetch,
|
|
503
540
|
async dispatchEffect(effect, executionOptions = {}) {
|
|
504
|
-
if (
|
|
505
|
-
|
|
541
|
+
if (effect.kind === 'crypto.digestText') return require('@pulse-compute/crypto/provider').executeTextDigest(effect, executionOptions);
|
|
542
|
+
if (effect.kind === 'time.now') return portableRuntimeHost.readWallTime(baseOptions.wallClock === undefined ? () => Date.now() : baseOptions.wallClock);
|
|
543
|
+
if (['s3.head', 's3.getText', 's3.putText', 's3.getBody'].includes(effect.kind)) {
|
|
544
|
+
const result = await require('./s3-reader.js').readS3(effect, { ...baseOptions, ...executionOptions,
|
|
506
545
|
fetchImplementation: executionOptions.s3FetchImplementation || baseOptions.s3FetchImplementation
|
|
507
546
|
|| executionOptions.fetchImplementation || baseOptions.fetchImplementation
|
|
508
547
|
}, (name) => bindingValue(executionOptions, 'secrets', name));
|
|
548
|
+
if (effect.operation !== 'getBody') return result;
|
|
549
|
+
if (result.body) executionOptions.registerResponseBody?.(result.body);
|
|
550
|
+
return { status: result.status, kind: 'stream', headers: [...result.headers], bodyStream: result.body || undefined };
|
|
509
551
|
}
|
|
510
552
|
if (effect.kind === 'fetch') return dispatchFetch(effect, executionOptions);
|
|
511
|
-
if (effect.kind === 'jwt.verify') return verifyNativeJwt(effect, executionOptions);
|
|
553
|
+
if (effect.kind === 'jwt.verify' || effect.kind === 'jwt.sign') return verifyNativeJwt(effect, executionOptions);
|
|
512
554
|
if (effect.kind === 'grip.broadcast') {
|
|
513
555
|
const grip = executionOptions.grip || baseOptions.grip || {};
|
|
514
556
|
const broadcast = createNodeJavascriptGripBroadcast({
|
|
@@ -554,7 +596,7 @@ function createNodeProviderAdapter(baseOptions = {}) {
|
|
|
554
596
|
return eventAdapter.acceptOutbound(effect.frame, executionOptions);
|
|
555
597
|
}
|
|
556
598
|
if (effect.kind === 'assets.lookup') {
|
|
557
|
-
return assetResponse(effect.payload || {}, kvStoresFor(executionOptions));
|
|
599
|
+
return assetResponse(effect.payload || {}, kvStoresFor(executionOptions), executionOptions.request);
|
|
558
600
|
}
|
|
559
601
|
throw new hostRuntime.CanonicalRuntimeError('ProviderCapabilityError', 'PULSE_PROVIDER_CAPABILITY_UNSUPPORTED', `Node provider does not implement ${effect.kind}.`, { provider: 'node', kind: effect.kind, effectId: effect.id });
|
|
560
602
|
},
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Explicit finite test-harness collector. HTTP execution never takes this path.
|
|
4
|
+
const { Writable } = require('node:stream');
|
|
5
|
+
const { createRequestBudget, PulseRuntimeContractError } = require('@pulse-compute/runtime/host');
|
|
6
|
+
const { createGeneratedOutput } = require('./generated-output.js');
|
|
7
|
+
|
|
8
|
+
function collector(options) {
|
|
9
|
+
const chunks = [], headers = new Map();
|
|
10
|
+
const response = new Writable({ write(chunk, _encoding, done) { chunks.push(Buffer.from(chunk)); done(); } });
|
|
11
|
+
response.setHeader = (name, value) => headers.set(name.toLowerCase(), value);
|
|
12
|
+
response.hasHeader = name => headers.has(name.toLowerCase());
|
|
13
|
+
const budget = options.requestBudget || createRequestBudget(options);
|
|
14
|
+
const outputExecution = createGeneratedOutput(response, { ...options, requestBudget: budget });
|
|
15
|
+
return {
|
|
16
|
+
outputExecution, budget,
|
|
17
|
+
body: () => Buffer.concat(chunks).toString('utf8'),
|
|
18
|
+
headers: () => [...headers],
|
|
19
|
+
dispose() { outputExecution?.dispose(); if (!options.requestBudget) budget.close(); }
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function executeNative(native, options, execute) {
|
|
24
|
+
if (!native.plan.capabilities.some(id => ['response.output', 'request.body.transform'].includes(id)) || options.outputExecution) return execute(native, options);
|
|
25
|
+
if (!options.outputCollect || !options.generatedOutput) throw new PulseRuntimeContractError('PULSE_OUTPUT_UNAVAILABLE', 'Generated output needs an HTTP writer or an explicit finite test collector.');
|
|
26
|
+
const collected = collector({ ...options, requestMethod: options.request?.method || 'GET' });
|
|
27
|
+
try {
|
|
28
|
+
const result = await execute(native, { ...options, requestBudget: collected.budget, signal: collected.budget.signal, outputExecution: collected.outputExecution });
|
|
29
|
+
if (!collected.outputExecution.started) return result;
|
|
30
|
+
await collected.outputExecution.finish();
|
|
31
|
+
return Object.freeze({ ...result, response: Object.freeze({ ...result.response, body: collected.body(), headers: collected.headers() }) });
|
|
32
|
+
} finally { collected.dispose(); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { collector, executeNative };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { validateHeaderName, validateHeaderValue } = require('node:http');
|
|
4
|
+
const { PulseRuntimeContractError } = require('@pulse-compute/runtime/host');
|
|
5
|
+
const limits = Object.freeze({ chunkBytes: 16384, writes: 64, totalBytes: 1048576 });
|
|
6
|
+
const error = (code, message) => new PulseRuntimeContractError(code, message);
|
|
7
|
+
|
|
8
|
+
function normalizeGeneratedOutput(value, duration) {
|
|
9
|
+
if (value === undefined) return undefined;
|
|
10
|
+
if (value !== true || !Number.isInteger(duration) || duration < 1 || duration > 30000) {
|
|
11
|
+
throw error('PULSE_OUTPUT_CONFIG_INVALID', 'node.generatedOutput must be true and requires node.maxDurationMs (1–30000).');
|
|
12
|
+
}
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// One request-owned writer. It never stores a queue of application chunks.
|
|
17
|
+
function createGeneratedOutput(response, options) {
|
|
18
|
+
if (!options.generatedOutput) return undefined;
|
|
19
|
+
normalizeGeneratedOutput(options.generatedOutput, options.maxDurationMs);
|
|
20
|
+
const transform = require('./body-transform.js');
|
|
21
|
+
transform.normalizeBodyTransform(options.bodyTransform, options);
|
|
22
|
+
let input, inputBytes = 0, inputDone = false, readStarted = false;
|
|
23
|
+
const budget = options.requestBudget;
|
|
24
|
+
if (!budget || budget.deadlineMonotonicMs === undefined) throw error('PULSE_OUTPUT_UNAVAILABLE', 'Generated output requires a shared request deadline.');
|
|
25
|
+
let phase = 'idle', committed = false, busy = false, bytes = 0, writes = 0, failure, completion;
|
|
26
|
+
let status = 200, headers = [];
|
|
27
|
+
const completions = new WeakSet();
|
|
28
|
+
function cancel(reason) {
|
|
29
|
+
if (phase === 'finished') return;
|
|
30
|
+
failure ||= reason || error('PULSE_OUTPUT_CANCELLED', 'Generated output was cancelled.');
|
|
31
|
+
phase = 'failed';
|
|
32
|
+
if (committed && !response.destroyed) response.destroy();
|
|
33
|
+
}
|
|
34
|
+
const removeAbort = budget.onAbort(() => cancel(budget.signal.reason));
|
|
35
|
+
function check() { if (failure) throw failure; budget.check(); }
|
|
36
|
+
function assertEffect() {
|
|
37
|
+
check();
|
|
38
|
+
if (phase === 'closing' || phase === 'finished') throw error('PULSE_OUTPUT_CLOSED', 'No effect may run after output.close().');
|
|
39
|
+
}
|
|
40
|
+
async function dispatch(effect) {
|
|
41
|
+
try {
|
|
42
|
+
assertEffect();
|
|
43
|
+
if (busy) throw error('PULSE_OUTPUT_OWNERSHIP', 'Generated output allows one awaited write at a time.');
|
|
44
|
+
busy = true;
|
|
45
|
+
if (effect.kind === 'output.readTextChunk') {
|
|
46
|
+
if (!options.bodyTransform || !input) throw error('PULSE_TRANSFORM_UNAVAILABLE', 'Text chunks require configured Node transform input.');
|
|
47
|
+
if (inputDone) throw error('PULSE_TRANSFORM_CLOSED', 'Transform input has reached EOF.');
|
|
48
|
+
readStarted = true;
|
|
49
|
+
const chunk = await input.readTextChunk(budget);
|
|
50
|
+
check();
|
|
51
|
+
inputBytes += Buffer.byteLength(chunk.text);
|
|
52
|
+
inputDone = chunk.done;
|
|
53
|
+
return chunk;
|
|
54
|
+
}
|
|
55
|
+
if (effect.kind === 'output.start') {
|
|
56
|
+
if (phase !== 'idle') throw error('PULSE_OUTPUT_OWNERSHIP', 'Generated output may start once.');
|
|
57
|
+
const init = effect.argument0 || {};
|
|
58
|
+
if (typeof init !== 'object' || Array.isArray(init) || Object.keys(init).some(key => !['status', 'headers'].includes(key))) {
|
|
59
|
+
throw error('PULSE_OUTPUT_OPTIONS_INVALID', 'Generated output accepts only status and headers.');
|
|
60
|
+
}
|
|
61
|
+
status = init.status === undefined ? 200 : init.status;
|
|
62
|
+
if (!Number.isInteger(status) || status < 200 || status > 599 || [204, 205, 304].includes(status)
|
|
63
|
+
|| String(options.requestMethod).toUpperCase() === 'HEAD') {
|
|
64
|
+
throw error('PULSE_OUTPUT_BODYLESS', 'Generated output requires a response that permits a body.');
|
|
65
|
+
}
|
|
66
|
+
headers = Array.isArray(init.headers) ? init.headers : Object.entries(init.headers || {});
|
|
67
|
+
if (headers.length > 64) throw error('PULSE_OUTPUT_LIMIT_EXCEEDED', 'Too many generated output headers.');
|
|
68
|
+
let headerBytes = 0;
|
|
69
|
+
headers = headers.map(pair => {
|
|
70
|
+
if (!Array.isArray(pair) || pair.length !== 2 || pair.some(value => typeof value !== 'string')) throw error('PULSE_OUTPUT_OPTIONS_INVALID', 'Headers must be string pairs.');
|
|
71
|
+
const [name, value] = pair;
|
|
72
|
+
validateHeaderName(name); validateHeaderValue(name, value);
|
|
73
|
+
if (['content-length', 'transfer-encoding', 'connection', 'keep-alive', 'upgrade', 'trailer', 'te', 'host', 'proxy-connection'].includes(name.toLowerCase())) {
|
|
74
|
+
throw error('PULSE_OUTPUT_OPTIONS_INVALID', 'Generated output forbids framing and hop-by-hop headers.');
|
|
75
|
+
}
|
|
76
|
+
headerBytes += Buffer.byteLength(name) + Buffer.byteLength(value);
|
|
77
|
+
if (headerBytes > 16384) throw error('PULSE_OUTPUT_LIMIT_EXCEEDED', 'Generated output headers exceed 16 KiB.');
|
|
78
|
+
return Object.freeze([name, value]);
|
|
79
|
+
});
|
|
80
|
+
check();
|
|
81
|
+
response.statusCode = status;
|
|
82
|
+
for (const [name, value] of headers) response.setHeader(name, value);
|
|
83
|
+
if (!response.hasHeader?.('content-type')) response.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
84
|
+
phase = 'open'; committed = true;
|
|
85
|
+
response.flushHeaders?.();
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
if (effect.kind !== 'output.write' || phase !== 'open') throw error('PULSE_OUTPUT_OWNERSHIP', 'Call output.start() before output.write().');
|
|
89
|
+
const text = effect.argument0;
|
|
90
|
+
if (typeof text !== 'string') throw error('PULSE_OUTPUT_CHUNK_INVALID', 'Generated output writes require text.');
|
|
91
|
+
// Bound before UTF-8 allocation; byte length remains authoritative.
|
|
92
|
+
if (text.length > limits.chunkBytes) throw error('PULSE_OUTPUT_LIMIT_EXCEEDED', 'Generated output chunk exceeds 16 KiB.');
|
|
93
|
+
const length = Buffer.byteLength(text);
|
|
94
|
+
if (length > limits.chunkBytes || writes === limits.writes || length > limits.totalBytes - bytes) throw error('PULSE_OUTPUT_LIMIT_EXCEEDED', 'Generated output exceeded its chunk, write-count or total-byte limit.');
|
|
95
|
+
if (options.bodyTransform && (bytes + length > transform.limits.outputBytes || bytes + length > inputBytes * transform.limits.expansion)) {
|
|
96
|
+
throw error('PULSE_TRANSFORM_OUTPUT_LIMIT', 'Transform output exceeds 262144 bytes or four times delivered input bytes.');
|
|
97
|
+
}
|
|
98
|
+
writes++; bytes += length;
|
|
99
|
+
await waitForWrite(Buffer.from(text));
|
|
100
|
+
check();
|
|
101
|
+
} catch (reason) { cancel(reason); throw failure; }
|
|
102
|
+
finally { busy = false; }
|
|
103
|
+
}
|
|
104
|
+
function waitForWrite(chunk) {
|
|
105
|
+
return budget.race(new Promise((resolve, reject) => {
|
|
106
|
+
let callbackDone = false, drained = false, returned = false, settled = false;
|
|
107
|
+
const cleanup = () => { response.removeListener('error', fail); response.removeListener('close', closed); response.removeListener('drain', drain); budget.signal.removeEventListener('abort', aborted); };
|
|
108
|
+
const fail = reason => { if (settled) return; settled = true; cleanup(); reject(reason); };
|
|
109
|
+
const done = () => { if (returned && callbackDone && drained && !settled) { settled = true; cleanup(); resolve(); } };
|
|
110
|
+
const closed = () => fail(error('PULSE_OUTPUT_CANCELLED', 'Output writer closed before completion.'));
|
|
111
|
+
const aborted = () => fail(budget.signal.reason);
|
|
112
|
+
const drain = () => { drained = true; done(); };
|
|
113
|
+
response.once('error', fail); response.once('close', closed); response.on('drain', drain); budget.signal.addEventListener('abort', aborted, { once: true });
|
|
114
|
+
try {
|
|
115
|
+
check();
|
|
116
|
+
const accepted = response.write(chunk, reason => { if (reason) fail(reason); else { callbackDone = true; done(); } });
|
|
117
|
+
drained ||= accepted; returned = true; done();
|
|
118
|
+
} catch (reason) { fail(reason); }
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
return Object.freeze({
|
|
122
|
+
dispatch, assertEffect, cancel,
|
|
123
|
+
bindInput(value) {
|
|
124
|
+
if (!options.bodyTransform) return;
|
|
125
|
+
if (input && input !== value) throw error('PULSE_REQUEST_BODY_OWNERSHIP', 'Transform input is already bound.');
|
|
126
|
+
input = value;
|
|
127
|
+
},
|
|
128
|
+
get started() { return phase !== 'idle'; },
|
|
129
|
+
get finished() { return phase === 'finished'; },
|
|
130
|
+
close(factory) {
|
|
131
|
+
try {
|
|
132
|
+
check();
|
|
133
|
+
if (phase !== 'open' || busy) throw error('PULSE_OUTPUT_OWNERSHIP', 'Return output.close() after all writes settle.');
|
|
134
|
+
if (options.bodyTransform && !inputDone) throw error('PULSE_TRANSFORM_EOF_REQUIRED', 'Read transform input through EOF before output.close().');
|
|
135
|
+
phase = 'closing';
|
|
136
|
+
completion = factory ? factory({ status, headers }) : Object.freeze({ status, headers, kind: 'text', bodyClass: 'structured', body: '' });
|
|
137
|
+
completions.add(completion);
|
|
138
|
+
return completion;
|
|
139
|
+
} catch (reason) { cancel(reason); throw failure; }
|
|
140
|
+
},
|
|
141
|
+
validateResult(result) {
|
|
142
|
+
check();
|
|
143
|
+
if ((phase !== 'idle' || readStarted) && (phase !== 'closing' || !completions.has(result))) throw error('PULSE_OUTPUT_CLOSE_REQUIRED', 'A generated-output handler must return its output.close() result.');
|
|
144
|
+
},
|
|
145
|
+
async finish() {
|
|
146
|
+
if (phase === 'idle') return;
|
|
147
|
+
try {
|
|
148
|
+
check();
|
|
149
|
+
if (phase !== 'closing') throw error('PULSE_OUTPUT_CLOSE_REQUIRED', 'Generated output did not close.');
|
|
150
|
+
await budget.race(new Promise((resolve, reject) => {
|
|
151
|
+
const cleanup = () => { response.removeListener('finish', done); response.removeListener('error', fail); response.removeListener('close', closed); budget.signal.removeEventListener('abort', aborted); };
|
|
152
|
+
const done = () => { cleanup(); resolve(); };
|
|
153
|
+
const fail = reason => { cleanup(); reject(reason); };
|
|
154
|
+
const closed = () => { if (!response.writableFinished) fail(error('PULSE_OUTPUT_CANCELLED', 'Output closed before finish.')); };
|
|
155
|
+
const aborted = () => fail(budget.signal.reason);
|
|
156
|
+
response.once('finish', done); response.once('error', fail); response.once('close', closed); budget.signal.addEventListener('abort', aborted, { once: true });
|
|
157
|
+
try { check(); response.end(); } catch (reason) { fail(reason); }
|
|
158
|
+
}));
|
|
159
|
+
check(); phase = 'finished';
|
|
160
|
+
} catch (reason) { cancel(reason); throw failure; }
|
|
161
|
+
},
|
|
162
|
+
dispose() { removeAbort(); if (!['idle', 'finished'].includes(phase)) cancel(); },
|
|
163
|
+
snapshot() { return Object.freeze({ phase, bytes, writes, ...(options.bodyTransform ? { inputBytes, inputDone, expansionRatio: inputBytes ? bytes / inputBytes : 0 } : {}) }); }
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { limits, normalizeGeneratedOutput, createGeneratedOutput };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const host = require('@pulse-compute/runtime/host');
|
|
4
|
+
const { createIncomingBody } = require('../javascript/incoming-body.js');
|
|
5
|
+
const { createNodeJavascriptFixtureFetch } = require('../javascript/fetch-adapter.js');
|
|
6
|
+
const { nodeRequestToWebRequest } = require('../javascript/node-adapter.js');
|
|
7
|
+
|
|
8
|
+
function forwardingOptions(options) {
|
|
9
|
+
return {
|
|
10
|
+
...options,
|
|
11
|
+
fetchImplementation: createNodeJavascriptFixtureFetch(options.fetches || {},
|
|
12
|
+
options.fetchImplementation || (options.liveFetch === true ? globalThis.fetch : undefined), { rawResponse: true })
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function prepareNativeRequest(request, options) {
|
|
17
|
+
const adapted = await nodeRequestToWebRequest(request, options);
|
|
18
|
+
let incomingBody;
|
|
19
|
+
try {
|
|
20
|
+
incomingBody = createIncomingBody(adapted.request, {
|
|
21
|
+
...forwardingOptions(options), responseWriterOwnsCompletion: true
|
|
22
|
+
});
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (adapted.request.body && !adapted.request.body.locked) void adapted.request.body.cancel(error).catch(() => {});
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
return Object.freeze({
|
|
28
|
+
request: { method: adapted.method, url: adapted.request.url, headers: adapted.headerPairs },
|
|
29
|
+
options: { incomingBody },
|
|
30
|
+
get signal() { return incomingBody.responseSignal || options.signal; },
|
|
31
|
+
close() { return incomingBody.close(); }
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function executeNativeWithIncomingBody(native, options) {
|
|
36
|
+
const { executeCanonicalNativeModule } = require('@pulse-compute/wasm-host-runtime/runtime/canonical-native-host');
|
|
37
|
+
if (!options.bodyTransform && !native.plan.capabilities.includes('request.body.forward')) return executeCanonicalNativeModule(native, options);
|
|
38
|
+
if (!options.bodyForwarding && !options.bodyTransform) throw new host.PulseRuntimeContractError('PULSE_REQUEST_FORWARDING_UNAVAILABLE', 'Node Native forwarding requires node.bodyForwarding.');
|
|
39
|
+
const forwardedBodies = new Set();
|
|
40
|
+
let incomingBody = options.incomingBody, result;
|
|
41
|
+
if (!incomingBody) {
|
|
42
|
+
const input = options.request || {};
|
|
43
|
+
const method = input.method || 'GET';
|
|
44
|
+
const request = new Request(input.url || 'http://pulse.local' + (input.path || '/'), {
|
|
45
|
+
method, headers: input.headers,
|
|
46
|
+
...(!['GET','HEAD'].includes(method) && input.body !== undefined ? {body:input.body,duplex:'half'} : {})
|
|
47
|
+
});
|
|
48
|
+
incomingBody = createIncomingBody(request, forwardingOptions(options));
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
options.outputExecution?.bindInput(incomingBody);
|
|
52
|
+
result = await executeCanonicalNativeModule(native, { ...options, incomingBody, forwardedBodies });
|
|
53
|
+
if (incomingBody.failure) throw incomingBody.failure;
|
|
54
|
+
return result;
|
|
55
|
+
} finally {
|
|
56
|
+
for (const body of forwardedBodies) if (body !== result?.response.bodyStream) {
|
|
57
|
+
try { void body.cancel().catch(() => {}); } catch (_) {}
|
|
58
|
+
}
|
|
59
|
+
if (!options.incomingBody) await incomingBody.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { prepareNativeRequest, executeNativeWithIncomingBody };
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
const {
|
|
4
4
|
CRYPTO_ES256_GUEST_LINKED_IMPLEMENTATION,
|
|
5
|
+
CRYPTO_RS256_GUEST_LINKED_IMPLEMENTATION, CRYPTO_RS256_GUEST_LINKED_REALIZATION,
|
|
5
6
|
CRYPTO_ES256_GUEST_LINKED_REALIZATION,
|
|
6
7
|
CRYPTO_GUEST_SOURCE_IMPLEMENTATION
|
|
7
8
|
} = require('@pulse-compute/wasm-contracts/crypto/contracts');
|
|
8
9
|
|
|
9
10
|
const NODE_NATIVE_JWT_VERIFIER_VERSION = 'pulse.node-native-jwt-verifier.v1';
|
|
10
11
|
const NODE_NATIVE_JWT_REALIZATIONS = Object.freeze({
|
|
12
|
+
'HMAC-SHA256': Object.freeze({ algorithm: 'HMAC-SHA256', realization: 'guest-source:pulse-hmac-as',
|
|
13
|
+
implementation: CRYPTO_GUEST_SOURCE_IMPLEMENTATION, guestUnitRequired: false }),
|
|
11
14
|
HS256: Object.freeze({
|
|
12
15
|
algorithm: 'HS256',
|
|
13
16
|
realization: 'guest-source:pulse-hmac-as',
|
|
@@ -19,6 +22,12 @@ const NODE_NATIVE_JWT_REALIZATIONS = Object.freeze({
|
|
|
19
22
|
realization: CRYPTO_ES256_GUEST_LINKED_REALIZATION,
|
|
20
23
|
implementation: CRYPTO_ES256_GUEST_LINKED_IMPLEMENTATION,
|
|
21
24
|
guestUnitRequired: true
|
|
25
|
+
}),
|
|
26
|
+
RS256: Object.freeze({
|
|
27
|
+
algorithm: 'RS256',
|
|
28
|
+
realization: CRYPTO_RS256_GUEST_LINKED_REALIZATION,
|
|
29
|
+
implementation: CRYPTO_RS256_GUEST_LINKED_IMPLEMENTATION,
|
|
30
|
+
guestUnitRequired: true
|
|
22
31
|
})
|
|
23
32
|
});
|
|
24
33
|
const NODE_NATIVE_JWT_REALIZATION = Object.freeze({
|
|
@@ -122,10 +131,10 @@ function keyArtifact(executionOptions, artifactId, jwtProvider) {
|
|
|
122
131
|
const data = artifact && dataRecord(artifact.get('data'));
|
|
123
132
|
if (
|
|
124
133
|
!artifact
|
|
125
|
-
|| artifact.
|
|
134
|
+
|| !['pulse.jwt-es256-key-artifact.v1', 'pulse.jwt-rs256-key-artifact.v1'].includes(artifact.get('version'))
|
|
126
135
|
|| artifact.get('contractId') !== 'pulse.jwt'
|
|
127
136
|
|| artifact.get('package') !== '@pulse-compute/jwt'
|
|
128
|
-
||
|
|
137
|
+
|| !['jwt-es256-static-public-key', 'jwt-rs256-static-public-key'].includes(artifact.get('kind'))
|
|
129
138
|
|| !data
|
|
130
139
|
) {
|
|
131
140
|
throw jwtProvider.jwtError('PULSE_JWT_KEY_INVALID', {
|
|
@@ -205,11 +214,23 @@ function createNodeNativeJwtVerify(baseOptions = {}) {
|
|
|
205
214
|
return async function verifyNodeNativeJwt(effect, executionOptions = {}) {
|
|
206
215
|
const jwtProvider = await loadJwtProvider();
|
|
207
216
|
requestActive(executionOptions, jwtProvider);
|
|
208
|
-
const
|
|
217
|
+
const signing = effect.kind === 'jwt.sign';
|
|
218
|
+
let input;
|
|
219
|
+
if (signing) {
|
|
220
|
+
const resource = dataRecord(effect.resource), payload = dataRecord(effect.payload);
|
|
221
|
+
if (effect.contractId !== 'pulse.jwt' || effect.package !== '@pulse-compute/jwt'
|
|
222
|
+
|| effect.operation !== 'sign' || effect.capability !== 'jwt.sign'
|
|
223
|
+
|| !resource || resource.size !== 3 || resource.get('keyType') !== 'secret'
|
|
224
|
+
|| resource.get('keyArtifactId') !== null || !payload || ![3, 4].includes(payload.size) || [...payload.keys()].some(name => !['claims', 'algorithm', 'expiresInSeconds', 'kid'].includes(name))) {
|
|
225
|
+
throw jwtProvider.jwtError('PULSE_JWT_OPERATION_FAILED', { category: 'sign-input' });
|
|
226
|
+
}
|
|
227
|
+
input = { claims: payload.get('claims'), options: { algorithm: payload.get('algorithm'),
|
|
228
|
+
expiresInSeconds: payload.get('expiresInSeconds'), key: { type: 'secret', binding: resource.get('secretBinding') }, ...(payload.has('kid') ? { kid: payload.get('kid') } : {}) } };
|
|
229
|
+
} else input = effectInput(effect, executionOptions, jwtProvider);
|
|
209
230
|
const selectedCrypto = requireCryptoVerifier(
|
|
210
231
|
executionOptions,
|
|
211
232
|
jwtProvider,
|
|
212
|
-
input.options.algorithms[0]
|
|
233
|
+
signing ? (input.options.algorithm === 'HS256' ? 'HMAC-SHA256' : input.options.algorithm) : input.options.algorithms[0]
|
|
213
234
|
);
|
|
214
235
|
const execution = dataRecord(executionOptions);
|
|
215
236
|
const executionClock = execution && execution.get('captureJwtWallClock');
|
|
@@ -246,7 +267,7 @@ function createNodeNativeJwtVerify(baseOptions = {}) {
|
|
|
246
267
|
return result;
|
|
247
268
|
};
|
|
248
269
|
}
|
|
249
|
-
const result = await jwtProvider.verifyJwtWithCrypto(
|
|
270
|
+
const result = await (signing ? jwtProvider.signJwtWithCrypto : jwtProvider.verifyJwtWithCrypto)(
|
|
250
271
|
input,
|
|
251
272
|
Object.freeze(host),
|
|
252
273
|
selectedCrypto
|