@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.
@@ -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
- const body = method === 'HEAD' ? '' : Buffer.from(await response.arrayBuffer()).toString('utf8');
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({
@@ -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 body = Object.prototype.hasOwnProperty.call(spec, 'body') ? spec.body : JSON.stringify(Object.prototype.hasOwnProperty.call(spec, 'value') ? spec.value : {});
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
- return require('./s3-reader.js').readS3(effect, { ...baseOptions, ...executionOptions,
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.get('version') !== 'pulse.jwt-es256-key-artifact.v1'
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
- || artifact.get('kind') !== 'jwt-es256-static-public-key'
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 input = effectInput(effect, executionOptions, jwtProvider);
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