@pulse-compute/provider-node 1.0.0-beta.4 → 1.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/package.json +14 -10
- package/src/capabilities.js +19 -3
- package/src/config/s3.js +1 -1
- package/src/javascript/fetch-adapter.js +16 -1
- package/src/javascript/incoming-body.js +163 -0
- package/src/javascript/jwt-verifier.js +14 -4
- package/src/javascript/lifecycle.js +4 -2
- package/src/javascript/node-adapter.js +170 -118
- package/src/javascript/package-effects.js +5 -2
- package/src/javascript/runtime-host.js +13 -3
- package/src/javascript/s3.js +4 -3
- package/src/javascript/target-support-policy.js +17 -4
- package/src/javascript/target.js +5 -0
- package/src/javascript/test-runtime.js +9 -1
- package/src/native/target.js +25 -0
- package/src/runtime/body-transform.js +87 -0
- package/src/runtime/canonical-api-runtime.js +53 -11
- package/src/runtime/generated-output-test.js +35 -0
- package/src/runtime/generated-output.js +167 -0
- package/src/runtime/incoming-body.js +63 -0
- package/src/runtime/jwt-verifier.js +26 -5
- package/src/runtime/s3-reader.js +76 -12
- package/src/server/build.js +53 -0
- package/src/server/config.js +54 -0
- package/src/server/index.d.ts +53 -0
- package/src/server/index.js +146 -0
- package/src/toolchain.js +24 -6
package/src/runtime/s3-reader.js
CHANGED
|
@@ -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() +
|
|
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(); },
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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–
|
|
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) =>
|
|
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'
|