@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.
@@ -38,34 +38,57 @@ function abortable(promise, signal) {
38
38
  });
39
39
  }
40
40
  async function readS3(effect, options, lookup) {
41
- const operation = effect.operation, put = operation === 'putText';
41
+ const operation = effect.operation, put = operation === 'putText', bodyRead = operation === 'getBody';
42
42
  const payload = effect.payload || {};
43
- if (!['head', 'getText', 'putText'].includes(operation) || effect.kind !== `s3.${operation}` || effect.capability !== effect.kind
43
+ if (!['head', 'getText', 'putText', 'getBody'].includes(operation) || effect.kind !== `s3.${operation}` || effect.capability !== effect.kind
44
44
  || effect.package !== '@pulse-compute/s3' || effect.contractId !== 'pulse.s3' || effect.providerKind !== 's3') throw new TypeError('Invalid S3 package effect authority.');
45
45
  let dispatched = false, status;
46
- const failure = (reason) => put ? protocol.putFailure(reason, dispatched, status) : protocol.failure(reason, status);
46
+ const failure = (reason) => bodyRead ? new Response(null, { status: reason === 'timeout' ? 504 : 502, headers: { 'x-pulse-s3-error': reason } }) : put ? protocol.putFailure(reason, dispatched, status) : protocol.failure(reason, status);
47
47
  if (options.signal && options.signal.aborted) throw options.signal.reason || new Error('Request cancelled.');
48
48
  const configured = typeof payload.binding === 'string' && options.s3 && Object.hasOwn(options.s3, payload.binding) && options.s3[payload.binding];
49
49
  let binding;
50
50
  try { binding = protocol.normalizeBinding(configured); } catch { return failure('configuration'); }
51
+ const timeoutMs = Math.min(binding.timeoutMs, options.requestBudget?.remainingMs() ?? Infinity);
51
52
  const controller = new AbortController();
52
53
  const onAbort = () => controller.abort(options.signal.reason);
53
54
  if (options.signal) options.signal.addEventListener('abort', onAbort, { once: true });
55
+ const bodyAbort = () => controller.abort(options.responseSignal.reason);
56
+ if (options.responseSignal) {
57
+ options.responseSignal.addEventListener('abort', bodyAbort, { once: true });
58
+ if (options.responseSignal.aborted) bodyAbort();
59
+ }
54
60
  let timedOut = false;
55
- const deadline = performance.now() + binding.timeoutMs;
61
+ const deadline = performance.now() + timeoutMs;
56
62
  const check = () => {
63
+ if (!transferred) options.requestBudget?.check();
57
64
  if (performance.now() >= deadline) { timedOut = true; controller.abort(); }
58
65
  if (controller.signal.aborted) throw controller.signal.reason || new Error('S3 deadline expired.');
59
66
  };
60
- const timer = setTimeout(() => { timedOut = true; controller.abort(); }, binding.timeoutMs);
67
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
61
68
  const wait = async (value) => { const result = await abortable(Promise.resolve(value), controller.signal); check(); return result; };
62
- let response;
69
+ let response, transferred = false, cleaned = false;
70
+ const cleanup = () => {
71
+ if (cleaned) return;
72
+ cleaned = true;
73
+ clearTimeout(timer);
74
+ if (options.signal) options.signal.removeEventListener('abort', onAbort);
75
+ options.responseSignal?.removeEventListener('abort', bodyAbort);
76
+ controller.abort();
77
+ if (response) { try { response.close(); } catch {} }
78
+ };
63
79
  try {
64
80
  const key = protocol.encodeKey(payload.key);
65
81
  if (key === null) return failure('invalid-key');
82
+ let readOptions;
83
+ if (bodyRead) {
84
+ try { readOptions = protocol.normalizeBodyOptions({ method: payload.method, ...(payload.range === undefined ? {} : { range: payload.range }), ...(payload.ifNoneMatch === undefined ? {} : { ifNoneMatch: payload.ifNoneMatch }) }); }
85
+ catch { return failure('configuration'); }
86
+ }
66
87
  let body, contentType;
67
88
  if (put) {
68
- if (typeof payload.text !== 'string' || !payload.text.isWellFormed()) return failure('invalid-text');
89
+ if (typeof payload.text !== 'string') return failure('invalid-text');
90
+ if (payload.text.length > binding.maxTextBytes) return failure('too-large');
91
+ if (!payload.text.isWellFormed()) return failure('invalid-text');
69
92
  body = protocol.bytes(payload.text);
70
93
  if (body.length > binding.maxTextBytes) return failure('too-large');
71
94
  try { contentType = protocol.normalizePutOptions({ contentType: payload.contentType }).contentType; }
@@ -85,7 +108,7 @@ async function readS3(effect, options, lookup) {
85
108
  if (!crypto || !Array.isArray(algorithms) || ['SHA-256', 'HMAC-SHA256'].some((algorithm) => !algorithms.some((entry) => entry.algorithm === algorithm && entry.available && entry.realization === realization))) return failure('configuration');
86
109
  let request;
87
110
  try {
88
- request = await wait(protocol.signRequest({ binding, method: put ? 'PUT' : operation === 'head' ? 'HEAD' : 'GET', encodedKey: key, accessId, secret, token, now: Date.now(), body, contentType }, crypto));
111
+ request = await wait(protocol.signRequest({ binding, method: bodyRead ? readOptions.method : put ? 'PUT' : operation === 'head' ? 'HEAD' : 'GET', encodedKey: key, accessId, secret, token, now: Date.now(), body, contentType, readOptions }, crypto));
89
112
  } catch (error) { check(); return failure('configuration'); }
90
113
  check();
91
114
  // The send primitive may have side effects even if it throws synchronously.
@@ -95,6 +118,15 @@ async function readS3(effect, options, lookup) {
95
118
  : await originRequest(request, controller.signal);
96
119
  status = response.status;
97
120
  check();
121
+ if (bodyRead) {
122
+ let metadata;
123
+ try { metadata = protocol.bodyResponseMetadata(status, response.headers, readOptions, binding.maxTextBytes); }
124
+ catch (error) { return failure(error.message === 'too-large' ? 'too-large' : 'protocol'); }
125
+ if (!metadata.body) return new Response(null, { status, headers: metadata.headers });
126
+ const stream = boundedBody(response, metadata.byteLength, binding.maxTextBytes, controller.signal, check, cleanup);
127
+ transferred = true;
128
+ return new Response(stream, { status, headers: metadata.headers });
129
+ }
98
130
  const rejected = put ? protocol.putStatusResult(status) : protocol.statusResult(status);
99
131
  if (rejected && (!put || rejected.status === 'unknown')) return rejected;
100
132
  const metadata = protocol.metadataFromHeaders(response.headers, operation === 'head');
@@ -122,10 +154,42 @@ async function readS3(effect, options, lookup) {
122
154
  if (options.signal && options.signal.aborted) throw options.signal.reason || error;
123
155
  return failure(timedOut ? 'timeout' : status === undefined ? 'transport' : 'protocol');
124
156
  } finally {
125
- clearTimeout(timer);
126
- if (options.signal) options.signal.removeEventListener('abort', onAbort);
127
- controller.abort();
128
- if (response) { try { await response.close(); } catch {} }
157
+ if (!transferred) cleanup();
129
158
  }
130
159
  }
160
+ function boundedBody(response, expected, maximum, signal, check, cleanup) {
161
+ const iterator = response.body?.[Symbol.asyncIterator]();
162
+ let total = 0, ended = false, abort;
163
+ const finish = () => { ended = true; signal.removeEventListener('abort', abort); cleanup(); };
164
+ return new ReadableStream({
165
+ start(controller) {
166
+ abort = () => {
167
+ if (ended) return;
168
+ controller.error(new Error('S3 body transfer aborted.'));
169
+ finish();
170
+ };
171
+ signal.addEventListener('abort', abort, { once: true });
172
+ if (signal.aborted) abort();
173
+ },
174
+ async pull(controller) {
175
+ try {
176
+ check();
177
+ const item = iterator ? await iterator.next() : { done: true };
178
+ if (ended) return;
179
+ check();
180
+ if (item.done) {
181
+ if (total !== expected) throw new Error('S3 body length mismatch.');
182
+ controller.close(); finish(); return;
183
+ }
184
+ if (!(item.value instanceof Uint8Array)) throw new Error('Invalid S3 body bytes.');
185
+ total += item.value.byteLength;
186
+ if (total > maximum || total > expected) throw new Error('S3 body exceeds its bound.');
187
+ controller.enqueue(item.value);
188
+ } catch {
189
+ if (!ended) { controller.error(new Error('S3 body transfer failed.')); finish(); }
190
+ }
191
+ },
192
+ cancel() { finish(); }
193
+ }, { highWaterMark: 0 });
194
+ }
131
195
  module.exports = { readS3, originRequest };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { createHash } = require('node:crypto');
6
+ const { instantiateCanonicalNativeModule } = require('@pulse-compute/wasm-host-runtime/runtime/canonical-native-host');
7
+ const { createNodeProviderAdapter } = require('../runtime/canonical-api-runtime.js');
8
+ const { assertNodeJavascriptApplication } = require('../javascript/runtime-host.js');
9
+
10
+ const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
11
+ const stable = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
12
+ ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
13
+
14
+ function loadBuild(options) {
15
+ const root = fs.realpathSync(options.buildDir);
16
+ function file(name) {
17
+ if (typeof name !== 'string' || !name || path.isAbsolute(name) || name.split(/[\\/]/).includes('..')) throw new TypeError('Invalid Node build file path.');
18
+ const absolute = fs.realpathSync(path.join(root, name));
19
+ if (!absolute.startsWith(root + path.sep) || !fs.statSync(absolute).isFile()) throw new TypeError('Node build file escapes buildDir.');
20
+ return absolute;
21
+ }
22
+ const json = name => JSON.parse(fs.readFileSync(file(name), 'utf8'));
23
+ const build = json('pulse-build.json');
24
+ if (build.version !== 'pulse.project-execution.v10' || build.status !== 'built' || build.provider !== 'node' || build.configuredTarget !== options.target) throw new TypeError('Node build provider/target mismatch.');
25
+ const capabilities = options.target === 'native' ? build.program?.capabilities : build.targetSupport?.project?.capabilities?.filter(item => item.required).map(item => item.id);
26
+ if ((Array.isArray(capabilities) ? capabilities : []).some(id => ['response.output', 'request.body.forward', 'request.body.transform', 's3.getBody'].includes(id))) {
27
+ throw new TypeError('This build needs a stream/body launcher extension outside NODE-01.');
28
+ }
29
+ if (options.target === 'javascript') {
30
+ const manifest = json(build.application.sourcePackage);
31
+ if (manifest.version !== 'pulse.node-javascript-source-package.v2' || manifest.provider !== 'node' || manifest.target !== 'javascript' || manifest.plan.planHash !== build.application.planHash) throw new TypeError('Invalid Node JavaScript source package.');
32
+ for (const module of manifest.modules) {
33
+ if (sha256(fs.readFileSync(file(module.output))) !== module.outputSha256) throw new TypeError('Node JavaScript module hash mismatch.');
34
+ }
35
+ const application = assertNodeJavascriptApplication(require(file(manifest.package.entry)));
36
+ const schemaCodecs = manifest.schemas.active ? require(file(manifest.package.schemaCodecs)) : undefined;
37
+ return { application, schemaCodecs, identity: build.application.planHash };
38
+ }
39
+ const portable = build.portable;
40
+ const plan = json(portable.plan);
41
+ const unsigned = { ...plan }; delete unsigned.planHash;
42
+ if (plan.planHash !== portable.planHash || sha256(stable(unsigned)) !== plan.planHash) throw new TypeError('Node Native plan hash mismatch.');
43
+ const wasm = fs.readFileSync(file(portable.wasm.file));
44
+ if (wasm.length !== portable.wasm.bytes || sha256(wasm) !== portable.wasm.sha256) throw new TypeError('Node Native Wasm hash mismatch.');
45
+ const realizationArtifacts = portable.packageRealizationArtifacts.file ? json(portable.packageRealizationArtifacts.file) : [];
46
+ if (sha256(stable(realizationArtifacts)) !== portable.packageRealizationArtifacts.sha256) throw new TypeError('Node Native package artifact hash mismatch.');
47
+ const native = { wasm, plan, realizationArtifacts };
48
+ const preflight = instantiateCanonicalNativeModule(native, { providerAdapter: createNodeProviderAdapter(), ...options });
49
+ preflight.close();
50
+ return { native, identity: plan.planHash };
51
+ }
52
+
53
+ module.exports = { loadBuild };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const { normalizeRequestDuration } = require('@pulse-compute/runtime/host');
4
+
5
+ const defaults = Object.freeze({
6
+ host: '127.0.0.1', port: 8787, readinessPath: '/_pulse/ready',
7
+ maxDurationMs: 10000, shutdownTimeoutMs: 10000, headersTimeoutMs: 10000,
8
+ keepAliveTimeoutMs: 5000, maxRequestBodyBytes: 65536, maxFetchBodyBytes: 65536,
9
+ maxEffects: 128, maxConcurrentRequests: 128, maxConnections: 512, networkFetch: false, strict: true
10
+ });
11
+
12
+ function normalizeOptions(input) {
13
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('Node launcher options are required.');
14
+ const allowed = new Set([...Object.keys(defaults), 'target', 'buildDir', 'config', 'secrets', 'kv']);
15
+ for (const key of Reflect.ownKeys(input)) {
16
+ if (!allowed.has(key)) throw new TypeError('Unknown Node launcher option.');
17
+ if (!Object.hasOwn(Object.getOwnPropertyDescriptor(input, key), 'value')) throw new TypeError('Node launcher options must be data properties.');
18
+ }
19
+ const options = { ...defaults, ...input };
20
+ if (!['native', 'javascript'].includes(options.target)) throw new TypeError('Explicit Node target native or javascript is required.');
21
+ if (typeof options.buildDir !== 'string' || !options.buildDir) throw new TypeError('Node launcher buildDir is required.');
22
+ if (typeof options.host !== 'string' || !options.host.trim()) throw new TypeError('Node launcher host must be nonempty.');
23
+ if (typeof options.strict !== 'boolean') throw new TypeError('Node launcher strict must be boolean.');
24
+ if (typeof options.networkFetch !== 'boolean') throw new TypeError('Node launcher networkFetch must be boolean.');
25
+ if (typeof options.readinessPath !== 'string' || !/^\/[A-Za-z0-9_/-]+$/.test(options.readinessPath) || options.readinessPath.includes('//')) {
26
+ throw new TypeError('Node launcher readinessPath must be a non-root absolute path without a query.');
27
+ }
28
+ for (const key of ['port', 'shutdownTimeoutMs', 'headersTimeoutMs', 'keepAliveTimeoutMs', 'maxRequestBodyBytes', 'maxFetchBodyBytes', 'maxEffects', 'maxConcurrentRequests', 'maxConnections']) {
29
+ const maximum = key === 'port' ? 65535 : key.endsWith('Ms') ? 30000 : key.endsWith('Bytes') ? 2097152 : 65536;
30
+ if (!Number.isSafeInteger(options[key]) || options[key] < (key === 'port' ? 0 : 1) || options[key] > maximum) throw new TypeError(`Invalid Node launcher ${key}.`);
31
+ }
32
+ normalizeRequestDuration(options.maxDurationMs);
33
+ if (options.maxDurationMs === undefined) throw new TypeError('Node launcher requires a finite maxDurationMs.');
34
+ for (const key of ['config', 'secrets']) {
35
+ const value = options[key] ?? {};
36
+ if (!value || typeof value !== 'object' || Array.isArray(value) || ![null, Object.prototype].includes(Object.getPrototypeOf(value))) throw new TypeError(`Node launcher ${key} must be a string record.`);
37
+ const entries = [];
38
+ for (const name of Reflect.ownKeys(value)) {
39
+ const descriptor = Object.getOwnPropertyDescriptor(value, name);
40
+ if (typeof name !== 'string' || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'string') throw new TypeError(`Node launcher ${key} must be a string record.`);
41
+ entries.push([name, descriptor.value]);
42
+ }
43
+ options[key] = Object.freeze(Object.fromEntries(entries));
44
+ }
45
+ // JSON seed data is process-local reference KV, never a durable store.
46
+ if (options.kv !== undefined) {
47
+ if (!options.kv || typeof options.kv !== 'object' || Array.isArray(options.kv)) throw new TypeError('Node launcher kv must be an object.');
48
+ require('../runtime/conditional-kv.js').createNodeKvReference({ kv: options.kv });
49
+ options.kv = structuredClone(options.kv);
50
+ }
51
+ return Object.freeze(options);
52
+ }
53
+
54
+ module.exports = { normalizeOptions };
@@ -0,0 +1,53 @@
1
+ /** Host integration only. Pass an immutable trusted directory emitted by pulse build. */
2
+ export interface NodeLauncherOptions {
3
+ target: 'native' | 'javascript';
4
+ buildDir: string;
5
+ /** Default 127.0.0.1. Set explicitly for container ingress. */
6
+ host?: string;
7
+ /** Default 8787; 0 requests an ephemeral port. */
8
+ port?: number;
9
+ /** Reserved GET/HEAD endpoint; default /_pulse/ready. */
10
+ readinessPath?: string;
11
+ /** One request budget across body admission, effects and response completion. 1–30000; default 10000. */
12
+ maxDurationMs?: number;
13
+ /** Drain grace period, 1–30000; default 10000. */
14
+ shutdownTimeoutMs?: number;
15
+ /** Header admission timeout, 1–30000; default 10000. */
16
+ headersTimeoutMs?: number;
17
+ /** Idle keep-alive timeout, 1–30000; default 5000. */
18
+ keepAliveTimeoutMs?: number;
19
+ /** Each 1–2097152; defaults 65536. */
20
+ maxRequestBodyBytes?: number;
21
+ maxFetchBodyBytes?: number;
22
+ /** Each 1–65536; defaults 128, 128, 512 respectively. */
23
+ maxEffects?: number;
24
+ maxConcurrentRequests?: number;
25
+ maxConnections?: number;
26
+ /** Explicit outbound fetch authorization; default false. */
27
+ networkFetch?: boolean;
28
+ /** Strict structured JSON policy; default true. Development profile values are not loaded. */
29
+ strict?: boolean;
30
+ config?: Readonly<Record<string, string>>;
31
+ secrets?: Readonly<Record<string, string>>;
32
+ /** In-memory reference KV seed. Reset on restart; no durability promise. */
33
+ kv?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
34
+ }
35
+ export interface NodeLauncherStatus {
36
+ readonly state: 'created' | 'starting' | 'ready' | 'draining' | 'stopped';
37
+ readonly target: 'native' | 'javascript';
38
+ readonly buildId: string;
39
+ readonly activeRequests: number;
40
+ readonly address: Readonly<{ host: string; port: number }> | null;
41
+ }
42
+ export interface NodeLauncherCloseResult {
43
+ readonly forced: boolean;
44
+ readonly abortedRequests: number;
45
+ }
46
+ export interface NodeLauncher {
47
+ /** Resolves after validated host initialization and successful listen. Idempotent while ready/starting. */
48
+ start(): Promise<NodeLauncherStatus>;
49
+ /** Immediately withdraws readiness, then drains. Does not install process handlers or call process.exit. */
50
+ close(): Promise<NodeLauncherCloseResult>;
51
+ status(): NodeLauncherStatus;
52
+ }
53
+ export function createNodeLauncher(options: NodeLauncherOptions): NodeLauncher;
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const { Readable } = require('node:stream');
5
+ const { createRequestBudget } = require('@pulse-compute/runtime/host');
6
+ const { executeCanonicalNativeModule } = require('@pulse-compute/wasm-host-runtime/runtime/canonical-native-host');
7
+ const { createNodeProviderAdapter } = require('../runtime/canonical-api-runtime.js');
8
+ const { createNodeKvReference } = require('../runtime/conditional-kv.js');
9
+ const { createNodeJavascriptHandler, nodeRequestToWebRequest, writeWebResponseToNode } = require('../javascript/node-adapter.js');
10
+ const { statusForError } = require('../javascript/lifecycle.js');
11
+ const { normalizeOptions } = require('./config.js');
12
+ const { loadBuild } = require('./build.js');
13
+
14
+ function safeError(response, error) {
15
+ if (response.destroyed) return;
16
+ if (response.headersSent) { response.destroy(); return; }
17
+ const status = statusForError(error);
18
+ response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' });
19
+ response.end(status === 504 ? 'Gateway Timeout' : status === 413 ? 'Payload Too Large' : status === 400 ? 'Bad Request' : 'Internal Server Error');
20
+ }
21
+
22
+ function nativeHandler(build, options) {
23
+ const providerAdapter = createNodeProviderAdapter(options);
24
+ return async (request, response, signal) => {
25
+ const budget = createRequestBudget({ ...options, signal });
26
+ try {
27
+ const adapted = await budget.race(nodeRequestToWebRequest(request, { ...options, signal: budget.signal }));
28
+ const input = { method: adapted.method, url: adapted.request.url, headers: adapted.headerPairs,
29
+ body: adapted.request.body ? Buffer.from(await budget.race(adapted.request.arrayBuffer())) : undefined };
30
+ const execution = await executeCanonicalNativeModule(build.native, {
31
+ ...options, providerAdapter, request: input, requestBudget: budget, signal: budget.signal,
32
+ maxBodyBytes: options.maxRequestBodyBytes
33
+ });
34
+ const result = execution.response;
35
+ const suppressed = adapted.method === 'HEAD' || [204, 205, 304].includes(result.status);
36
+ let body = result.bodyStream ?? result.body ?? null;
37
+ if (suppressed && result.bodyStream) {
38
+ if (typeof body.cancel === 'function') await body.cancel();
39
+ else body.destroy?.();
40
+ }
41
+ if (body && typeof body.pipe === 'function') body = Readable.toWeb(body);
42
+ const webResponse = new Response(suppressed ? null : body, { status: result.status, headers: result.headers });
43
+ await writeWebResponseToNode(webResponse, response, { requestMethod: adapted.method, requestBudget: budget, signal: budget.signal });
44
+ } finally { budget.close(); }
45
+ };
46
+ }
47
+
48
+ /** Create a production HTTP lifecycle over one immutable, trusted pulse build. */
49
+ function createNodeLauncher(input) {
50
+ const options = normalizeOptions(input);
51
+ let state = 'created', generation, starting, stopping, lastClose = Object.freeze({ forced: false, abortedRequests: 0 });
52
+ // Snapshot the build once; restart is a fresh host generation, not hot reload.
53
+ const build = loadBuild(options);
54
+ // Validate host bindings before entering any lifecycle transition.
55
+ const { createNodeJavascriptBindingCapabilities } = require('../javascript/bindings-adapter.js');
56
+ createNodeJavascriptBindingCapabilities(options);
57
+ function status() {
58
+ const address = generation?.server.address();
59
+ return Object.freeze({ state, target: options.target, buildId: build.identity,
60
+ activeRequests: generation?.active.size || 0,
61
+ address: address && typeof address === 'object' ? Object.freeze({ host: address.address, port: address.port }) : null });
62
+ }
63
+ function start() {
64
+ if (state === 'ready') return Promise.resolve(status());
65
+ if (state === 'starting') return starting;
66
+ if (state === 'draining') return Promise.reject(new Error('Node launcher is draining; await close before start.'));
67
+ state = 'starting';
68
+ const active = new Set(), sockets = new Set();
69
+ const execution = { ...options, kvReference: createNodeKvReference({ kv: structuredClone(options.kv || {}) }),
70
+ fetchImplementation: options.networkFetch ? globalThis.fetch : undefined,
71
+ liveFetch: options.networkFetch, schemaCodecs: build.schemaCodecs };
72
+ const javascript = options.target === 'javascript' ? createNodeJavascriptHandler(build.application, execution) : null;
73
+ const native = options.target === 'native' ? nativeHandler(build, execution) : null;
74
+ const current = { active, sockets, server: null, onIdle: null };
75
+ const server = http.createServer({
76
+ headersTimeout: options.headersTimeoutMs, requestTimeout: Math.max(options.maxDurationMs, options.headersTimeoutMs),
77
+ keepAliveTimeout: options.keepAliveTimeoutMs, maxHeaderSize: 16384
78
+ }, (request, response) => {
79
+ const reject = (code, message) => { response.writeHead(code, { connection: 'close', 'content-type': 'text/plain; charset=utf-8' }); response.end(message); };
80
+ const pathname = (request.url || '/').split('?')[0];
81
+ if (pathname === options.readinessPath) {
82
+ response.setHeader('cache-control', 'no-store');
83
+ response.setHeader('connection', 'close');
84
+ if (!['GET', 'HEAD'].includes(request.method)) { response.setHeader('allow', 'GET, HEAD'); reject(405, 'Method Not Allowed'); return; }
85
+ response.statusCode = state === 'ready' && generation === current ? 200 : 503;
86
+ response.end(request.method === 'HEAD' ? undefined : response.statusCode === 200 ? 'ready' : 'not ready');
87
+ return;
88
+ }
89
+ if (state !== 'ready' || generation !== current || active.size >= options.maxConcurrentRequests) { reject(503, 'Service Unavailable'); return; }
90
+ const controller = new AbortController();
91
+ active.add(controller);
92
+ const disconnect = () => controller.abort(new Error('Node launcher request disconnected.'));
93
+ const closed = () => { if (!response.writableFinished) disconnect(); };
94
+ request.once('aborted', disconnect); response.once('close', closed);
95
+ // Each invocation owns its own cancellation signal, including forced drain.
96
+ const run = javascript
97
+ ? javascript(request, response, controller.signal)
98
+ : native(request, response, controller.signal);
99
+ Promise.resolve(run).catch(error => safeError(response, error)).finally(() => {
100
+ request.removeListener('aborted', disconnect); response.removeListener('close', closed);
101
+ active.delete(controller); current.onIdle?.();
102
+ });
103
+ });
104
+ current.server = server; generation = current;
105
+ server.maxConnections = options.maxConnections;
106
+ server.on('connection', socket => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)); });
107
+ // This listener remains after listen to prevent an unhandled server error.
108
+ server.on('error', () => { if (state === 'ready' && generation === current) void close(); });
109
+ starting = new Promise((resolve, reject) => {
110
+ const error = () => { server.removeListener('listening', ready); state = 'stopped'; reject(new Error('Node launcher could not listen.')); };
111
+ const ready = () => { server.removeListener('error', error); state = 'ready'; resolve(status()); };
112
+ server.once('error', error); server.once('listening', ready);
113
+ try { server.listen(options.port, options.host); } catch (cause) { server.removeListener('error', error); error(); }
114
+ });
115
+ return starting;
116
+ }
117
+ function close() {
118
+ if (state === 'starting') return starting.then(() => close(), () => lastClose);
119
+ if (state === 'draining') return stopping;
120
+ if (state === 'created' || state === 'stopped') { state = 'stopped'; return Promise.resolve(lastClose); }
121
+ state = 'draining';
122
+ const current = generation;
123
+ stopping = new Promise(resolve => {
124
+ let closed = false, finished = false;
125
+ const finish = result => {
126
+ if (finished) return;
127
+ finished = true; clearTimeout(timer); current.onIdle = null;
128
+ state = 'stopped'; lastClose = Object.freeze(result); resolve(lastClose);
129
+ };
130
+ const check = () => { if (closed && current.active.size === 0) finish({ forced: false, abortedRequests: 0 }); };
131
+ const timer = setTimeout(() => {
132
+ const abortedRequests = current.active.size;
133
+ for (const controller of current.active) controller.abort(new Error('Node launcher drain deadline exceeded.'));
134
+ for (const socket of current.sockets) socket.destroy();
135
+ finish({ forced: true, abortedRequests });
136
+ }, options.shutdownTimeoutMs);
137
+ current.onIdle = check;
138
+ current.server.close(() => { closed = true; check(); });
139
+ current.server.closeIdleConnections();
140
+ });
141
+ return stopping;
142
+ }
143
+ return Object.freeze({ start, close, status });
144
+ }
145
+
146
+ module.exports = { createNodeLauncher };
package/src/toolchain.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('node:path');
4
+ const { PulseRuntimeContractError: portableForwardingError } = require('@pulse-compute/runtime/host');
4
5
  const {
5
6
  PROVIDER_DRIVER_VERSION,
6
7
  PROVIDER_TARGET_RESULT_VERSION,
@@ -36,7 +37,11 @@ function genericProviderConfig(value) {
36
37
  const input = value && typeof value === 'object' ? value : {};
37
38
  const bindings = input.bindings || {};
38
39
  if (Object.keys(bindings).some((key) => key !== 's3')) throw new TypeError('Unknown Node binding field.');
39
- return Object.freeze({ kind: 'node', bindings: Object.freeze({ s3: require('./config/s3.js').normalizeNodeS3(bindings.s3) }), local: Object.freeze({}) });
40
+ const maxDurationMs = require('@pulse-compute/runtime/host').normalizeRequestDuration(input.maxDurationMs);
41
+ const bodyForwarding = require('./javascript/incoming-body.js').normalizeBodyForwarding(input.bodyForwarding, maxDurationMs);
42
+ const generatedOutput = require('./runtime/generated-output.js').normalizeGeneratedOutput(input.generatedOutput, maxDurationMs);
43
+ const bodyTransform = require('./runtime/body-transform.js').normalizeBodyTransform(input.bodyTransform, { generatedOutput, maxDurationMs, bodyForwarding });
44
+ return Object.freeze({ ...(bodyTransform ? { bodyTransform } : {}), kind: 'node', maxDurationMs, ...(generatedOutput ? { generatedOutput } : {}), ...(bodyForwarding ? { bodyForwarding } : {}), bindings: Object.freeze({ s3: require('./config/s3.js').normalizeNodeS3(bindings.s3) }), local: Object.freeze({}) });
40
45
  }
41
46
 
42
47
  function nodeRealization(nativeArtifact) {
@@ -68,6 +73,9 @@ function nodeRealization(nativeArtifact) {
68
73
  function targetSupportContext(_compiled, _project, declaration) {
69
74
  const gates = declaration.availability && declaration.availability.gates || [];
70
75
  return Object.freeze({
76
+ bodyTransform: Boolean(_project?.providerConfig?.bodyTransform),
77
+ generatedOutput: Boolean(_project?.providerConfig?.generatedOutput),
78
+ bodyForwarding: Boolean(_project?.providerConfig?.bodyForwarding),
71
79
  bindingsRedaction: gates.some((entry) => entry.id === 'bindings-redaction' && entry.status === 'satisfied'),
72
80
  gripRealized: gates.some((entry) => entry.id === 'grip-readiness' && entry.evidence && entry.evidence.status === 'passed')
73
81
  });
@@ -144,18 +152,22 @@ function createDriver() {
144
152
  normalizeConfig: genericProviderConfig,
145
153
  configReference: Object.freeze({
146
154
  sections: [{ id: 'node', title: 'Node provider options', description: 'Provider-owned Node profile configuration.' }],
147
- fields: [{ section: 'node', path: 'node.bindings.s3', type: 'Readonly<Record<string, S3Binding>>', default: '`{}`', scope: 'Node Native and JavaScript S3',
148
- description: 'Maps literal logical names to fixed HTTPS endpoint, bucket, region, accessKeyIdSecret, secretAccessKeySecret, optional sessionTokenSecret, maxTextBytes (1–32768, default 32768) and timeoutMs (1–30000, default 10000).',
155
+ fields: [{ section:'node', path:'node.bodyTransform', type:'boolean', default:'omitted', scope:'Experimental Node UTF-8 transforms', description:'Opt-in finite request text chunks. Requires generatedOutput and maxDurationMs; incompatible with bodyForwarding. Input 65536 bytes, output 262144 bytes, at most 4 times delivered input bytes. Strict UTF-8, one reader/writer, EOF required before output.close().' }, { section:'node', path:'node.generatedOutput', type:'boolean', default:'omitted', scope:'Experimental Node generated output', description:'Opt-in finite UTF-8 output. Requires node.maxDurationMs. Each write is at most 16384 bytes; at most 64 writes and 1048576 bytes total. Use STR-03B for exact-candidate installed qualification.' }, { section:'node', path:'node.bodyForwarding', type:'{ maxBytes: number }', default:'omitted', scope:'Node incoming forwarding', description:'Opt-in single-use incoming POST forwarding. maxBytes is a positive safe integer limiting each transfer direction; requires node.maxDurationMs. Pulse emits chunks up to 16384 bytes and retains at most 65536 bytes per pump. Native uses exact Wasm execution and excludes structured request reads in the same application.' }, { section:'node', path:'node.maxDurationMs', type:'integer', allowed:'1–30000', default:'omitted', scope:'HTTP request execution', description:'One provider-owned monotonic budget shared by request effects and continuations; expiry does not prove rollback of dispatched writes.' }, { section: 'node', path: 'node.bindings.s3', type: 'Readonly<Record<string, S3Binding>>', default: '`{}`', scope: 'Node Native and JavaScript S3',
156
+ description: 'Maps literal logical names to fixed HTTPS endpoint, bucket, region, accessKeyIdSecret, secretAccessKeySecret, optional sessionTokenSecret, maxTextBytes (1–2097152, default 32768) and timeoutMs (1–30000, default 10000).',
149
157
  security: 'Only named credential references are configuration. Runtime keys cannot override authority.' }]
150
158
  }),
151
159
  defaultLocalNetworkFetch: true,
152
- projectConfigDocument(config) { return Object.keys(config.bindings.s3).length ? config : undefined; },
160
+ projectConfigDocument(config) { return (Object.keys(config.bindings.s3).length || config.maxDurationMs !== undefined) ? config : undefined; },
153
161
  initTemplate() {
154
162
  return Object.freeze({ profileFragment: '', dependencies: Object.freeze({}) });
155
163
  },
156
164
  executionOptions(config, values) {
157
165
  return Object.freeze({
158
166
  ...values,
167
+ maxDurationMs: config.maxDurationMs,
168
+ generatedOutput: config.generatedOutput,
169
+ bodyTransform: config.bodyTransform,
170
+ bodyForwarding: config.bodyForwarding,
159
171
  s3: config.bindings.s3,
160
172
  s3FetchImplementation: values.s3FetchImplementation || require('./javascript/fetch-adapter.js').createNodeJavascriptFixtureFetch(
161
173
  values.fetches || {}, values.fetchImplementation || (values.liveFetch === true ? globalThis.fetch : undefined),
@@ -220,11 +232,17 @@ function createDriver() {
220
232
  }),
221
233
  execute: nodeRuntime.executeCanonicalProgram,
222
234
  prepareNativeExecution(invocation) {
223
- const { executeCanonicalNativeModule } = require('@pulse-compute/wasm-host-runtime/runtime/canonical-native-host');
224
235
  const native = { ...invocation.nativeArtifact, plan: invocation.applicationPlan };
225
- return (options) => executeCanonicalNativeModule(native, options);
236
+ return (options) => require('./runtime/generated-output-test.js').executeNative(native, options, require('./runtime/incoming-body.js').executeNativeWithIncomingBody);
226
237
  },
238
+ createGeneratedOutput: require('./runtime/generated-output.js').createGeneratedOutput,
239
+ prepareNativeRequest: require('./runtime/incoming-body.js').prepareNativeRequest,
227
240
  createLoweringPlan(metadata, config = {}) {
241
+ if (metadata.capabilities?.includes('request.body.transform') && !config.bodyTransform) throw new portableForwardingError('PULSE_TRANSFORM_UNAVAILABLE', 'Configure node.bodyTransform, node.generatedOutput and node.maxDurationMs.');
242
+ if (metadata.capabilities?.includes('response.output') && !config.generatedOutput) throw new portableForwardingError('PULSE_OUTPUT_UNAVAILABLE', 'Configure node.generatedOutput and node.maxDurationMs.');
243
+ if (metadata.capabilities?.includes('request.body.forward')) {
244
+ if (!config.bodyForwarding) throw new portableForwardingError('PULSE_REQUEST_FORWARDING_UNAVAILABLE', 'Configure node.bodyForwarding and node.maxDurationMs.');
245
+ }
228
246
  require('./config/s3.js').validateNodeS3Operations(metadata, config.bindings || {});
229
247
  return canonicalProvider.createProviderLoweringPlan(metadata, nodeRuntime.NODE_PROVIDER_DESCRIPTOR, {
230
248
  grip: 'node-reference-broadcaster'