@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.
@@ -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);
@@ -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 = Number.isFinite(timeoutMs) && timeoutMs > 0 ? new AbortController() : undefined;
238
- const timeout = controller ? setTimeout(() => controller.abort(new Error(`Fetch timed out after ${timeoutMs}ms.`)), timeoutMs) : undefined;
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 (['s3.head', 's3.getText', 's3.putText'].includes(effect.kind)) {
505
- return require('./s3-reader.js').readS3(effect, { ...baseOptions, ...executionOptions,
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.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