@pulse-compute/provider-node 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.
- package/README.md +11 -9
- package/package.json +14 -10
- package/src/capabilities.js +13 -2
- package/src/config/s3.js +1 -1
- package/src/javascript/fetch-adapter.js +12 -1
- package/src/javascript/incoming-body.js +163 -0
- package/src/javascript/jwt-verifier.js +14 -4
- package/src/javascript/lifecycle.js +1 -1
- package/src/javascript/node-adapter.js +41 -8
- package/src/javascript/package-effects.js +1 -1
- package/src/javascript/runtime-host.js +7 -2
- package/src/javascript/s3.js +4 -3
- package/src/javascript/target-support-policy.js +15 -4
- package/src/javascript/target.js +5 -0
- package/src/javascript/test-runtime.js +9 -2
- package/src/native/target.js +25 -0
- package/src/runtime/body-transform.js +87 -0
- package/src/runtime/canonical-api-runtime.js +22 -7
- 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 +73 -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 +21 -4
|
@@ -42,7 +42,10 @@ async function executeNodeJavascriptTestCase(application, testCase, options = {}
|
|
|
42
42
|
options.networkFetch === true ? globalThis.fetch : undefined,
|
|
43
43
|
{ rawResponse: true }
|
|
44
44
|
);
|
|
45
|
+
const collected = options.generatedOutput ? require('../runtime/generated-output-test.js').collector({ ...options, requestMethod: adapted.request.method }) : undefined;
|
|
46
|
+
try {
|
|
45
47
|
const response = await executeNodeJavascriptApplication(application, adapted.request, {
|
|
48
|
+
outputExecution: collected?.outputExecution,
|
|
46
49
|
capabilities: options.capabilities,
|
|
47
50
|
effectAdapter: options.effectAdapter,
|
|
48
51
|
fetchImplementation: fixtureFetch,
|
|
@@ -60,7 +63,9 @@ async function executeNodeJavascriptTestCase(application, testCase, options = {}
|
|
|
60
63
|
kv: testCase.kv || {},
|
|
61
64
|
application: options.application,
|
|
62
65
|
requestHeaders: adapted.headerPairs,
|
|
63
|
-
maxDurationMs: options.maxDurationMs, requestClock: options.requestClock, requestBudget: options.requestBudget, signal: options.signal,
|
|
66
|
+
maxDurationMs: options.maxDurationMs, requestClock: options.requestClock, requestBudget: collected?.budget || options.requestBudget, signal: options.signal,
|
|
67
|
+
bodyForwarding: options.bodyForwarding,
|
|
68
|
+
bodyTransform: options.bodyTransform,
|
|
64
69
|
maxEffects: options.maxEffects,
|
|
65
70
|
maxRequestBodyBytes: options.maxRequestBodyBytes ?? options.maxBodyBytes ?? testCase.maxBodyBytes,
|
|
66
71
|
maxFetchBodyBytes: options.maxFetchBodyBytes ?? testCase.maxBodyBytes,
|
|
@@ -86,7 +91,8 @@ async function executeNodeJavascriptTestCase(application, testCase, options = {}
|
|
|
86
91
|
}
|
|
87
92
|
});
|
|
88
93
|
const method = String(testCase.request && testCase.request.method || 'GET').toUpperCase();
|
|
89
|
-
|
|
94
|
+
if (collected?.outputExecution.started) await collected.outputExecution.finish();
|
|
95
|
+
const body = collected?.outputExecution.started ? collected.body() : method === 'HEAD' ? '' : Buffer.from(await response.arrayBuffer()).toString('utf8');
|
|
90
96
|
return Object.freeze({
|
|
91
97
|
version: NODE_JAVASCRIPT_TEST_RUNTIME_VERSION,
|
|
92
98
|
response: Object.freeze({
|
|
@@ -107,6 +113,7 @@ async function executeNodeJavascriptTestCase(application, testCase, options = {}
|
|
|
107
113
|
}))),
|
|
108
114
|
resolutionOrder: effectSummary ? effectSummary.resolutionOrder : Object.freeze([])
|
|
109
115
|
});
|
|
116
|
+
} finally { collected?.dispose(); }
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
module.exports = Object.freeze({
|
package/src/native/target.js
CHANGED
|
@@ -30,6 +30,13 @@ const NODE_NATIVE_TARGET_DESCRIPTOR = Object.freeze({
|
|
|
30
30
|
capabilities: NODE_NATIVE_TARGET_CAPABILITIES,
|
|
31
31
|
keyTypes: Object.freeze(['secret', 'jwk', 'jwks']),
|
|
32
32
|
realizations: Object.freeze([
|
|
33
|
+
Object.freeze({
|
|
34
|
+
kind: 'package-native-application',
|
|
35
|
+
contractId: 'pulse.entities',
|
|
36
|
+
implementation: 'pulse.package-native-application.v1',
|
|
37
|
+
implemented: true,
|
|
38
|
+
automaticFallback: false
|
|
39
|
+
}),
|
|
33
40
|
Object.freeze({
|
|
34
41
|
kind: 'crypto-composed',
|
|
35
42
|
realization: 'guest-source:pulse-hmac-as',
|
|
@@ -55,6 +62,19 @@ const NODE_NATIVE_TARGET_DESCRIPTOR = Object.freeze({
|
|
|
55
62
|
guestUnitRequired: true,
|
|
56
63
|
portable: true,
|
|
57
64
|
automaticFallback: false
|
|
65
|
+
}),
|
|
66
|
+
Object.freeze({
|
|
67
|
+
kind: 'crypto-composed',
|
|
68
|
+
realization: 'guest-linked:pulse-rs256-bearssl-i31',
|
|
69
|
+
implementation: 'bearssl.0.6.rsa-i31.sha256.v1',
|
|
70
|
+
algorithms: Object.freeze(['RS256']),
|
|
71
|
+
keyTypes: Object.freeze(['jwk', 'jwks']),
|
|
72
|
+
implemented: true,
|
|
73
|
+
status: 'implemented-rs256',
|
|
74
|
+
semanticOwner: '@pulse-compute/crypto',
|
|
75
|
+
guestUnitRequired: true,
|
|
76
|
+
portable: true,
|
|
77
|
+
automaticFallback: false
|
|
58
78
|
})
|
|
59
79
|
]),
|
|
60
80
|
automaticFallback: false,
|
|
@@ -70,6 +90,11 @@ const NODE_NATIVE_TARGET_DESCRIPTOR = Object.freeze({
|
|
|
70
90
|
realization: 'guest-linked:pulse-es256-rustcrypto-p256',
|
|
71
91
|
implemented: true,
|
|
72
92
|
status: 'implemented-g3'
|
|
93
|
+
}, {
|
|
94
|
+
algorithm: 'RS256',
|
|
95
|
+
realization: 'guest-linked:pulse-rs256-bearssl-i31',
|
|
96
|
+
implemented: true,
|
|
97
|
+
status: 'implemented-rs256'
|
|
73
98
|
}]
|
|
74
99
|
}),
|
|
75
100
|
finalWasmPolicy: nodeFinalWasmPolicy
|
|
@@ -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);
|
|
@@ -486,6 +493,11 @@ function createNodeProviderAdapter(baseOptions = {}) {
|
|
|
486
493
|
function kvStoresFor(executionOptions) { return kvReferenceFor(executionOptions).legacyStores(); }
|
|
487
494
|
|
|
488
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
|
+
}
|
|
489
501
|
const fetchFixtures = normalizeFetchFixtures(executionOptions.fetches || baseOptions.fetches);
|
|
490
502
|
const providerData = buildProviderBackends(fetchFixtures);
|
|
491
503
|
const fixture = providerData.specs.get(`${normalized.init.method} ${normalized.parts.url}`) || providerData.specs.get(normalized.parts.url);
|
|
@@ -528,14 +540,17 @@ function createNodeProviderAdapter(baseOptions = {}) {
|
|
|
528
540
|
async dispatchEffect(effect, executionOptions = {}) {
|
|
529
541
|
if (effect.kind === 'crypto.digestText') return require('@pulse-compute/crypto/provider').executeTextDigest(effect, executionOptions);
|
|
530
542
|
if (effect.kind === 'time.now') return portableRuntimeHost.readWallTime(baseOptions.wallClock === undefined ? () => Date.now() : baseOptions.wallClock);
|
|
531
|
-
if (['s3.head', 's3.getText', 's3.putText'].includes(effect.kind)) {
|
|
532
|
-
|
|
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,
|
|
533
545
|
fetchImplementation: executionOptions.s3FetchImplementation || baseOptions.s3FetchImplementation
|
|
534
546
|
|| executionOptions.fetchImplementation || baseOptions.fetchImplementation
|
|
535
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 };
|
|
536
551
|
}
|
|
537
552
|
if (effect.kind === 'fetch') return dispatchFetch(effect, executionOptions);
|
|
538
|
-
if (effect.kind === 'jwt.verify') return verifyNativeJwt(effect, executionOptions);
|
|
553
|
+
if (effect.kind === 'jwt.verify' || effect.kind === 'jwt.sign') return verifyNativeJwt(effect, executionOptions);
|
|
539
554
|
if (effect.kind === 'grip.broadcast') {
|
|
540
555
|
const grip = executionOptions.grip || baseOptions.grip || {};
|
|
541
556
|
const broadcast = createNodeJavascriptGripBroadcast({
|
|
@@ -581,7 +596,7 @@ function createNodeProviderAdapter(baseOptions = {}) {
|
|
|
581
596
|
return eventAdapter.acceptOutbound(effect.frame, executionOptions);
|
|
582
597
|
}
|
|
583
598
|
if (effect.kind === 'assets.lookup') {
|
|
584
|
-
return assetResponse(effect.payload || {}, kvStoresFor(executionOptions));
|
|
599
|
+
return assetResponse(effect.payload || {}, kvStoresFor(executionOptions), executionOptions.request);
|
|
585
600
|
}
|
|
586
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 });
|
|
587
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
|