@pulse-compute/provider-node 1.0.0-beta.5 → 1.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/package.json +14 -10
- package/src/capabilities.js +13 -2
- package/src/config/s3.js +1 -1
- package/src/javascript/fetch-adapter.js +12 -1
- package/src/javascript/incoming-body.js +163 -0
- package/src/javascript/jwt-verifier.js +14 -4
- package/src/javascript/lifecycle.js +1 -1
- package/src/javascript/node-adapter.js +41 -8
- package/src/javascript/package-effects.js +1 -1
- package/src/javascript/runtime-host.js +7 -2
- package/src/javascript/s3.js +4 -3
- package/src/javascript/target-support-policy.js +15 -4
- package/src/javascript/target.js +5 -0
- package/src/javascript/test-runtime.js +9 -2
- package/src/native/target.js +25 -0
- package/src/runtime/body-transform.js +87 -0
- package/src/runtime/canonical-api-runtime.js +22 -7
- package/src/runtime/generated-output-test.js +35 -0
- package/src/runtime/generated-output.js +167 -0
- package/src/runtime/incoming-body.js +63 -0
- package/src/runtime/jwt-verifier.js +26 -5
- package/src/runtime/s3-reader.js +73 -12
- package/src/server/build.js +53 -0
- package/src/server/config.js +54 -0
- package/src/server/index.d.ts +53 -0
- package/src/server/index.js +146 -0
- package/src/toolchain.js +21 -4
package/README.md
CHANGED
|
@@ -2,20 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- pulse-package-status:start -->
|
|
4
4
|
> **Support tier:** Implementation/transitive surface<br>
|
|
5
|
-
> **Audience:**
|
|
6
|
-
> **Install directly:**
|
|
7
|
-
> **Supported entry points:** `@pulse-compute/provider-node/toolchain`<br>
|
|
8
|
-
> **Stability:** The
|
|
5
|
+
> **Audience:** Node deployment integrators and Pulse provider maintainers.<br>
|
|
6
|
+
> **Install directly:** Yes for the production Node launcher; application handlers remain provider-neutral.<br>
|
|
7
|
+
> **Supported entry points:** `@pulse-compute/provider-node/toolchain`, `@pulse-compute/provider-node/server`<br>
|
|
8
|
+
> **Stability:** The /server export is an explicit supported-entry-point exception within this implementation package: finite production HTTP for explicit Node Native and JavaScript builds. Forwarding, generated output, transforms, S3-body bindings and blob-specific production qualification remain separate. /toolchain serves the CLI; all other provider subpaths remain internal.<br>
|
|
9
9
|
> **npm:** [`@pulse-compute/provider-node`](https://www.npmjs.com/package/@pulse-compute/provider-node)<br>
|
|
10
|
-
> **Canonical
|
|
11
|
-
> **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.5/packages/implementation-packages/)
|
|
10
|
+
> **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.6/packages/implementation-packages/)
|
|
12
11
|
>
|
|
13
|
-
> This release-status block is generated from the synchronized `Pulse 1.0.0-beta.
|
|
12
|
+
> This release-status block is generated from the synchronized `Pulse 1.0.0-beta.6` package policy.
|
|
14
13
|
<!-- pulse-package-status:end -->
|
|
15
14
|
|
|
16
15
|
Canonical Node provider implementation used internally when a project selects `provider: 'node'`.
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
Deployment integrators install this package and import `@pulse-compute/provider-node/server`
|
|
18
|
+
to launch a trusted `pulse build` directory. Application handlers remain provider-neutral.
|
|
19
|
+
See [Node build and execution](https://pulsecompute.io/v1.0.0-beta.6/guides/deploying-node/)
|
|
20
|
+
for configuration, readiness, drain, signals and restart.
|
|
19
21
|
|
|
20
22
|
## Responsibilities
|
|
21
23
|
|
|
@@ -60,4 +62,4 @@ The package exposes focused compiler and runtime modules for the synchronized
|
|
|
60
62
|
repository, including `runtime/canonical-api-runtime`. Those exports are
|
|
61
63
|
implementation interfaces, not application-author APIs.
|
|
62
64
|
|
|
63
|
-
See [Contracts and providers](https://pulsecompute.io/v1.0.0-beta.
|
|
65
|
+
See [Contracts and providers](https://pulsecompute.io/v1.0.0-beta.6/concepts/contracts-and-providers/) and [Implementation packages](https://pulsecompute.io/v1.0.0-beta.6/packages/implementation-packages/).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pulse-compute/provider-node",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.6",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": "^22.14.0 || ^24.0.0"
|
|
@@ -39,16 +39,20 @@
|
|
|
39
39
|
"./javascript/package-effects": "./src/javascript/package-effects.js",
|
|
40
40
|
"./javascript/jwt-verifier": "./src/javascript/jwt-verifier.js",
|
|
41
41
|
"./javascript/grip-broadcast": "./src/javascript/grip-broadcast.js",
|
|
42
|
-
"./javascript/source-package": "./src/javascript/source-package.js"
|
|
42
|
+
"./javascript/source-package": "./src/javascript/source-package.js",
|
|
43
|
+
"./server": {
|
|
44
|
+
"types": "./src/server/index.d.ts",
|
|
45
|
+
"default": "./src/server/index.js"
|
|
46
|
+
}
|
|
43
47
|
},
|
|
44
48
|
"dependencies": {
|
|
45
|
-
"@pulse-compute/crypto": "1.0.0-beta.
|
|
46
|
-
"@pulse-compute/jwt": "1.0.0-beta.
|
|
47
|
-
"@pulse-compute/runtime": "1.0.0-beta.
|
|
48
|
-
"@pulse-compute/s3": "1.0.0-beta.
|
|
49
|
-
"@pulse-compute/wasm-build-support": "1.0.0-beta.
|
|
50
|
-
"@pulse-compute/wasm-contracts": "1.0.0-beta.
|
|
51
|
-
"@pulse-compute/wasm-host-runtime": "1.0.0-beta.
|
|
49
|
+
"@pulse-compute/crypto": "1.0.0-beta.6",
|
|
50
|
+
"@pulse-compute/jwt": "1.0.0-beta.6",
|
|
51
|
+
"@pulse-compute/runtime": "1.0.0-beta.6",
|
|
52
|
+
"@pulse-compute/s3": "1.0.0-beta.6",
|
|
53
|
+
"@pulse-compute/wasm-build-support": "1.0.0-beta.6",
|
|
54
|
+
"@pulse-compute/wasm-contracts": "1.0.0-beta.6",
|
|
55
|
+
"@pulse-compute/wasm-host-runtime": "1.0.0-beta.6"
|
|
52
56
|
},
|
|
53
57
|
"publishConfig": {
|
|
54
58
|
"access": "public"
|
|
@@ -64,7 +68,7 @@
|
|
|
64
68
|
"url": "git+https://github.com/pulse-compute/pulse.git",
|
|
65
69
|
"directory": "packages/provider-node"
|
|
66
70
|
},
|
|
67
|
-
"homepage": "https://pulsecompute.io/v1.0.0-beta.
|
|
71
|
+
"homepage": "https://pulsecompute.io/v1.0.0-beta.6/packages/implementation-packages/",
|
|
68
72
|
"bugs": {
|
|
69
73
|
"url": "https://github.com/pulse-compute/pulse/issues"
|
|
70
74
|
}
|
package/src/capabilities.js
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
const NODE_JWT_VERIFY_CAPABILITY = 'jwt.verify';
|
|
4
4
|
|
|
5
5
|
const NODE_CANONICAL_PROVIDER_CAPABILITIES = Object.freeze([
|
|
6
|
-
's3.head', 's3.getText', 's3.putText',
|
|
6
|
+
's3.head', 's3.getText', 's3.putText', 's3.getBody',
|
|
7
7
|
'request',
|
|
8
|
+
'request.body.forward',
|
|
9
|
+
'response.output',
|
|
10
|
+
'request.body.transform',
|
|
8
11
|
'response.json',
|
|
9
12
|
'response.text',
|
|
10
13
|
'response.custom',
|
|
@@ -21,14 +24,18 @@ const NODE_CANONICAL_PROVIDER_CAPABILITIES = Object.freeze([
|
|
|
21
24
|
'assets.lookup',
|
|
22
25
|
'grip.broadcast',
|
|
23
26
|
NODE_JWT_VERIFY_CAPABILITY,
|
|
27
|
+
'jwt.sign',
|
|
24
28
|
'opaque.pass-through'
|
|
25
29
|
]);
|
|
26
30
|
|
|
27
31
|
const NODE_CANONICAL_PROVIDER_LOWERING = Object.freeze({
|
|
28
32
|
request: 'node.http.request',
|
|
33
|
+
'request.body.forward': 'node.http.incoming-request-v1',
|
|
29
34
|
'response.json': 'node.http.response.json',
|
|
30
35
|
'response.text': 'node.http.response.text',
|
|
31
36
|
'response.custom': 'node.http.response.custom',
|
|
37
|
+
'request.body.transform': 'node.http.bounded-text-transform-v1',
|
|
38
|
+
'response.output': 'node.http.generated-output-v1',
|
|
32
39
|
fetch: 'node.fetch.dispatch',
|
|
33
40
|
'time.wall-clock': 'node.runtime.time.now',
|
|
34
41
|
'crypto.digestText': 'node.crypto.digest-text',
|
|
@@ -44,17 +51,21 @@ const NODE_CANONICAL_PROVIDER_LOWERING = Object.freeze({
|
|
|
44
51
|
's3.head': 'node.native.s3.head',
|
|
45
52
|
's3.getText': 'node.native.s3.getText',
|
|
46
53
|
's3.putText': 'node.native.s3.putText',
|
|
54
|
+
's3.getBody': 'node.s3.opaque-body',
|
|
47
55
|
'assets.lookup': 'node.runtime.assets.lookup',
|
|
48
56
|
'grip.broadcast': 'node.http.grip.publish',
|
|
49
57
|
[NODE_JWT_VERIFY_CAPABILITY]: 'node.native.jwt.verify',
|
|
58
|
+
'jwt.sign': 'node.native.jwt.sign',
|
|
50
59
|
'opaque.pass-through': 'node.http.response.stream'
|
|
51
60
|
});
|
|
52
61
|
|
|
53
62
|
const NODE_NATIVE_TARGET_CAPABILITIES = Object.freeze([
|
|
54
|
-
's3.head', 's3.getText', 's3.putText',
|
|
63
|
+
's3.head', 's3.getText', 's3.putText', 's3.getBody',
|
|
55
64
|
NODE_JWT_VERIFY_CAPABILITY,
|
|
65
|
+
'jwt.sign',
|
|
56
66
|
'jwt.verify.hs256',
|
|
57
67
|
'jwt.verify.es256',
|
|
68
|
+
'jwt.verify.rs256',
|
|
58
69
|
'secret.get',
|
|
59
70
|
'event.ingress',
|
|
60
71
|
'event.emit',
|
package/src/config/s3.js
CHANGED
|
@@ -13,7 +13,7 @@ function normalizeNodeS3(input = {}) {
|
|
|
13
13
|
}
|
|
14
14
|
function validateNodeS3Operations(metadata, bindings) {
|
|
15
15
|
for (const effect of metadata.providerOperations || []) {
|
|
16
|
-
if (!['s3.head', 's3.getText', 's3.putText'].includes(effect.kind)) continue;
|
|
16
|
+
if (!['s3.head', 's3.getText', 's3.putText', 's3.getBody'].includes(effect.kind)) continue;
|
|
17
17
|
if (!bindings.s3 || !Object.hasOwn(bindings.s3, effect.resource && effect.resource.binding)) throw new TypeError('The required Node S3 binding is missing.');
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -22,6 +22,9 @@ function createNodeJavascriptFetchCapability(options = {}) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
try {
|
|
25
|
+
if (init.body && typeof init.body === 'object') {
|
|
26
|
+
return await runtimeHost.forwardIncomingBody(init.body, String(url), init, execution);
|
|
27
|
+
}
|
|
25
28
|
const response = await implementation(String(url), {
|
|
26
29
|
method: init.method,
|
|
27
30
|
headers: init.headers,
|
|
@@ -136,6 +139,14 @@ function createNodeJavascriptFixtureFetch(fetches = {}, fallback, options = {})
|
|
|
136
139
|
}
|
|
137
140
|
await delayWithSignal(fixture.delayMs, init.signal);
|
|
138
141
|
|
|
142
|
+
// Incoming forwarding fixture transport consumes the already bounded
|
|
143
|
+
// provider stream without accumulating bytes or replaying it.
|
|
144
|
+
if (init.duplex === 'half' && init.body?.getReader) {
|
|
145
|
+
const reader = init.body.getReader();
|
|
146
|
+
try { while (!(await reader.read()).done) init.signal?.throwIfAborted(); }
|
|
147
|
+
finally { reader.releaseLock(); }
|
|
148
|
+
}
|
|
149
|
+
|
|
139
150
|
const status = Number(fixture.status || 200);
|
|
140
151
|
let body = null;
|
|
141
152
|
let defaultContentType;
|
|
@@ -169,7 +180,7 @@ function createNodeJavascriptFixtureFetch(fetches = {}, fallback, options = {})
|
|
|
169
180
|
&& status !== 304
|
|
170
181
|
&& !(status >= 100 && status < 200);
|
|
171
182
|
const response = new Response(bodyAllowed ? body : null, { status, headers });
|
|
172
|
-
if (options.rawResponse === true) return response;
|
|
183
|
+
if (options.rawResponse === true || init.duplex === 'half') return response;
|
|
173
184
|
return runtimeHost.createOpaqueFetchResponse({ response, headers: headerPairs });
|
|
174
185
|
};
|
|
175
186
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const host = require('@pulse-compute/runtime/host');
|
|
4
|
+
const CHUNK_BYTES = 16 * 1024;
|
|
5
|
+
const QUEUE_BYTES = 64 * 1024;
|
|
6
|
+
const error = (code, message) => new host.PulseRuntimeContractError(code, message);
|
|
7
|
+
|
|
8
|
+
function normalizeBodyForwarding(value, maxDurationMs) {
|
|
9
|
+
if (value === undefined) return undefined;
|
|
10
|
+
if (!value || typeof value !== 'object' || Object.keys(value).some(key => key !== 'maxBytes')
|
|
11
|
+
|| !Number.isSafeInteger(value.maxBytes) || value.maxBytes <= 0
|
|
12
|
+
|| !Number.isSafeInteger(maxDurationMs) || maxDurationMs < 1 || maxDurationMs > 30000) {
|
|
13
|
+
throw error('PULSE_REQUEST_FORWARDING_CONFIG_INVALID', 'node.bodyForwarding requires a positive safe integer maxBytes and node.maxDurationMs (1–30000).');
|
|
14
|
+
}
|
|
15
|
+
return Object.freeze({ maxBytes: value.maxBytes });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// One retained host chunk, one pending read, no whole-body accumulation. The
|
|
19
|
+
// host chunk contract is checked before retaining it; slices are copied so a
|
|
20
|
+
// small outgoing chunk cannot keep an oversized backing allocation alive.
|
|
21
|
+
function boundedBody(source, maxBytes, signal, observe, onClose) {
|
|
22
|
+
let reader, controller, pending, failure, offset = 0, ended = false, stopped = false;
|
|
23
|
+
const metrics = { bytes: 0, maxSourceChunkBytes: 0, maxChunkBytes: 0, maxQueuedBytes: 0 };
|
|
24
|
+
const release = () => { try { reader?.releaseLock(); } catch (_) {} };
|
|
25
|
+
function stop(reason) {
|
|
26
|
+
if (stopped || ended) return;
|
|
27
|
+
failure ||= reason;
|
|
28
|
+
stopped = true; pending = undefined;
|
|
29
|
+
try { Promise.resolve(reader ? reader.cancel(reason) : source?.cancel(reason)).catch(() => {}).finally(release); } catch (_) {}
|
|
30
|
+
try { reason ? controller.error(reason) : controller.close(); } catch (_) {}
|
|
31
|
+
signal?.removeEventListener('abort', abort);
|
|
32
|
+
onClose?.();
|
|
33
|
+
}
|
|
34
|
+
const abort = () => stop(signal.reason);
|
|
35
|
+
const stream = new ReadableStream({
|
|
36
|
+
start(value) { controller = value; signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort(); },
|
|
37
|
+
async pull(value) {
|
|
38
|
+
if (stopped || ended) return;
|
|
39
|
+
try {
|
|
40
|
+
signal?.throwIfAborted();
|
|
41
|
+
if (!pending) {
|
|
42
|
+
if (!source) { ended = true; value.close(); signal?.removeEventListener('abort', abort); onClose?.(); return; }
|
|
43
|
+
reader ||= source.getReader();
|
|
44
|
+
const next = await reader.read();
|
|
45
|
+
if (stopped) return;
|
|
46
|
+
signal?.throwIfAborted();
|
|
47
|
+
if (next.done) { ended = true; value.close(); release(); signal?.removeEventListener('abort', abort); onClose?.(); return; }
|
|
48
|
+
if (!(next.value instanceof Uint8Array) || next.value.byteLength > QUEUE_BYTES
|
|
49
|
+
|| next.value.buffer.byteLength > QUEUE_BYTES) {
|
|
50
|
+
throw error('PULSE_REQUEST_FORWARDING_CHUNK_INVALID', 'Provider body chunks and their backing allocations must not exceed 65536 bytes.');
|
|
51
|
+
}
|
|
52
|
+
metrics.bytes += next.value.byteLength;
|
|
53
|
+
if (metrics.bytes > maxBytes) throw error('PULSE_REQUEST_BODY_TOO_LARGE', 'Forwarded body exceeds the configured transfer limit.');
|
|
54
|
+
pending = next.value; offset = 0;
|
|
55
|
+
metrics.maxSourceChunkBytes = Math.max(metrics.maxSourceChunkBytes, pending.byteLength);
|
|
56
|
+
metrics.maxQueuedBytes = Math.max(metrics.maxQueuedBytes, pending.byteLength);
|
|
57
|
+
}
|
|
58
|
+
const count = Math.min(CHUNK_BYTES, pending.byteLength - offset);
|
|
59
|
+
if (count) {
|
|
60
|
+
value.enqueue(Uint8Array.from(pending.subarray(offset, offset + count)));
|
|
61
|
+
metrics.maxChunkBytes = Math.max(metrics.maxChunkBytes, count);
|
|
62
|
+
}
|
|
63
|
+
offset += count;
|
|
64
|
+
if (offset === pending.byteLength) pending = undefined;
|
|
65
|
+
observe?.(Object.freeze({ ...metrics }));
|
|
66
|
+
} catch (cause) { stop(cause); }
|
|
67
|
+
},
|
|
68
|
+
cancel(reason) { stop(reason); }
|
|
69
|
+
}, { highWaterMark: 0 });
|
|
70
|
+
return { stream, stop, metrics, get failure() { return failure; } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createIncomingBody(request, options) {
|
|
74
|
+
if (options.bodyTransform !== undefined) return require('../runtime/body-transform.js').createTransformInput(request, options);
|
|
75
|
+
const policy = normalizeBodyForwarding(options.bodyForwarding, options.maxDurationMs);
|
|
76
|
+
if (!policy) return undefined;
|
|
77
|
+
let upload, timeoutMs, timer, responseSignal, responseTransferred = false;
|
|
78
|
+
const clearTimer = () => { if (timer !== undefined) clearTimeout(timer); timer = undefined; };
|
|
79
|
+
function validate() {
|
|
80
|
+
const length = request.headers.get('content-length');
|
|
81
|
+
if (length !== null && (!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length)))) {
|
|
82
|
+
throw error('PULSE_REQUEST_FORWARDING_HEADERS_INVALID', 'Invalid incoming content length.');
|
|
83
|
+
}
|
|
84
|
+
if (length !== null && Number(length) > policy.maxBytes) throw error('PULSE_REQUEST_BODY_TOO_LARGE', 'Incoming body exceeds the configured transfer limit.');
|
|
85
|
+
}
|
|
86
|
+
validate();
|
|
87
|
+
return host.createIncomingBodyOwnership({
|
|
88
|
+
get responseSignal() { return responseSignal; },
|
|
89
|
+
validate(init) {
|
|
90
|
+
validate();
|
|
91
|
+
if (init.method !== 'POST') throw error('PULSE_FETCH_BODY_METHOD_UNSUPPORTED', 'Incoming forwarding requires an outbound POST.');
|
|
92
|
+
for (const [name] of init.headers) if (/^(host|content-length|transfer-encoding|connection|keep-alive|te|trailer|upgrade|proxy-connection)$/i.test(name)) {
|
|
93
|
+
throw error('PULSE_REQUEST_FORWARDING_HEADERS_INVALID', 'The provider owns forwarding framing and hop-by-hop headers.');
|
|
94
|
+
}
|
|
95
|
+
if (typeof options.fetchImplementation !== 'function') throw error('PULSE_FETCH_IMPLEMENTATION_UNAVAILABLE', 'Incoming forwarding requires provider-owned network transport.');
|
|
96
|
+
timeoutMs = init.timeoutMs;
|
|
97
|
+
},
|
|
98
|
+
async forward(url, init, execution) {
|
|
99
|
+
// The ordinary fetch effect settles at headers. Keep its shorter budget
|
|
100
|
+
// through response-body completion for this single-use transfer.
|
|
101
|
+
const timeout = new AbortController();
|
|
102
|
+
if (timeoutMs !== undefined) timer = setTimeout(() => timeout.abort(error('PULSE_FETCH_TIMEOUT', 'Incoming forwarding exceeded its fetch timeout.')), Math.min(timeoutMs, options.maxDurationMs));
|
|
103
|
+
const signal = AbortSignal.any([execution.signal, options.signal, timeout.signal].filter(Boolean));
|
|
104
|
+
responseSignal = signal;
|
|
105
|
+
upload = boundedBody(request.body, policy.maxBytes, signal, options.onBodyForwardingObservation);
|
|
106
|
+
let response;
|
|
107
|
+
try {
|
|
108
|
+
response = await options.fetchImplementation(url, {
|
|
109
|
+
method: 'POST', headers: init.headers, body: upload.stream,
|
|
110
|
+
signal, duplex: 'half', redirect: 'manual'
|
|
111
|
+
});
|
|
112
|
+
} catch (cause) { clearTimer(); throw upload.failure || cause; }
|
|
113
|
+
finally { upload.stop(); }
|
|
114
|
+
if (signal.aborted) {
|
|
115
|
+
clearTimer();
|
|
116
|
+
try { void response?.body?.cancel(signal.reason).catch(() => {}); } catch (_) {}
|
|
117
|
+
signal.throwIfAborted();
|
|
118
|
+
}
|
|
119
|
+
if (!(response instanceof Response)) throw error('PULSE_FETCH_RESPONSE_INVALID', 'Forwarding transport must return a Web Response.');
|
|
120
|
+
if (response.status >= 300 && response.status < 400) {
|
|
121
|
+
try { void response.body?.cancel().catch(() => {}); } catch (_) {}
|
|
122
|
+
throw error('PULSE_REQUEST_FORWARDING_REDIRECT', 'Single-use incoming bodies cannot be replayed on redirects.');
|
|
123
|
+
}
|
|
124
|
+
const responseBody = response.body && boundedBody(response.body, policy.maxBytes, signal, options.onBodyForwardingObservation,
|
|
125
|
+
() => { if (!options.responseWriterOwnsCompletion) clearTimer(); });
|
|
126
|
+
responseTransferred = Boolean(responseBody);
|
|
127
|
+
if (!responseBody && !options.responseWriterOwnsCompletion) clearTimer();
|
|
128
|
+
// Headers and status remain provider-owned. No incoming request headers
|
|
129
|
+
// are copied implicitly; the response bytes retain their ordinary form.
|
|
130
|
+
return new Response(responseBody ? responseBody.stream : null, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
131
|
+
},
|
|
132
|
+
cancel(reason) {
|
|
133
|
+
if (!responseTransferred || options.responseWriterOwnsCompletion) clearTimer();
|
|
134
|
+
upload?.stop(reason);
|
|
135
|
+
if (!upload && request.body && !request.body.locked) void request.body.cancel(reason).catch(() => {});
|
|
136
|
+
// Response ownership has transferred separately to the HTTP writer.
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Adapt IncomingMessage without switching it into flowing mode. Node's own
|
|
142
|
+
// transport buffer is separate from the Pulse queue and stays at most 64 KiB.
|
|
143
|
+
function nodeIncomingStream(request) {
|
|
144
|
+
if (request.readableHighWaterMark > QUEUE_BYTES) throw error('PULSE_REQUEST_FORWARDING_CHUNK_INVALID', 'Incoming Node transport high-water mark exceeds 65536 bytes.');
|
|
145
|
+
let stopped = false, wake;
|
|
146
|
+
const notify = () => { wake?.(); wake = undefined; };
|
|
147
|
+
for (const event of ['readable', 'end', 'error', 'aborted']) request.on(event, notify);
|
|
148
|
+
function cleanup() { for (const event of ['readable', 'end', 'error', 'aborted']) request.removeListener(event, notify); }
|
|
149
|
+
return new ReadableStream({
|
|
150
|
+
async pull(controller) {
|
|
151
|
+
while (!stopped) {
|
|
152
|
+
if (request.errored || request.aborted) { cleanup(); throw request.errored || error('PULSE_NODE_REQUEST_READ_FAILED', 'Incoming request was aborted.'); }
|
|
153
|
+
const chunk = request.read(Math.min(request.readableLength || CHUNK_BYTES, CHUNK_BYTES));
|
|
154
|
+
if (chunk) { controller.enqueue(Uint8Array.from(chunk)); return; }
|
|
155
|
+
if (request.readableEnded) { cleanup(); controller.close(); return; }
|
|
156
|
+
await new Promise(resolve => { wake = resolve; });
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
cancel() { stopped = true; request.pause(); cleanup(); notify(); }
|
|
160
|
+
}, { highWaterMark: 0 });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { normalizeBodyForwarding, boundedBody, createIncomingBody, nodeIncomingStream };
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const {
|
|
4
|
+
normalizeJwtSignEffect,
|
|
4
5
|
normalizeJwtVerifyEffect
|
|
5
6
|
} = require('@pulse-compute/wasm-contracts/jwt/contracts');
|
|
6
7
|
const {
|
|
7
8
|
CRYPTO_ES256_RUNTIME_BUILTIN_IMPLEMENTATION,
|
|
9
|
+
CRYPTO_RS256_RUNTIME_BUILTIN_IMPLEMENTATION,
|
|
8
10
|
CRYPTO_RUNTIME_BUILTIN_IMPLEMENTATION
|
|
9
11
|
} = require('@pulse-compute/wasm-contracts/crypto/contracts');
|
|
10
12
|
|
|
@@ -16,6 +18,7 @@ const NODE_JAVASCRIPT_JWT_REALIZATIONS = Object.freeze({
|
|
|
16
18
|
implementation: CRYPTO_RUNTIME_BUILTIN_IMPLEMENTATION,
|
|
17
19
|
automaticFallback: false
|
|
18
20
|
}),
|
|
21
|
+
RS256: Object.freeze({ algorithm: 'RS256', realization: 'runtime-builtin', implementation: CRYPTO_RS256_RUNTIME_BUILTIN_IMPLEMENTATION, automaticFallback: false }),
|
|
19
22
|
ES256: Object.freeze({
|
|
20
23
|
algorithm: 'ES256',
|
|
21
24
|
realization: 'runtime-builtin',
|
|
@@ -83,12 +86,19 @@ function createNodeJavascriptJwtVerify(options = {}) {
|
|
|
83
86
|
return async function verifyNodeJavascriptJwt(effect, execution) {
|
|
84
87
|
const { jwtProvider, cryptoProvider } = await loadRuntimeModules();
|
|
85
88
|
assertRequestActive(execution, jwtProvider);
|
|
86
|
-
const
|
|
87
|
-
|
|
89
|
+
const signing = effect.operation === 'sign';
|
|
90
|
+
const input = signing ? normalizeJwtSignEffect(effect) : normalizeJwtVerifyEffect(effect);
|
|
91
|
+
if (!signing && execution && typeof execution.registerRedactionValue === 'function') {
|
|
88
92
|
execution.registerRedactionValue(input.token);
|
|
89
93
|
}
|
|
90
94
|
assertRequestActive(execution, jwtProvider);
|
|
91
|
-
const selectedCrypto =
|
|
95
|
+
const selectedCrypto = signing
|
|
96
|
+
? (input.options.algorithm === 'RS256'
|
|
97
|
+
? require('@pulse-compute/crypto/provider').bindJavascriptRs256Signer()
|
|
98
|
+
: input.options.algorithm === 'ES256'
|
|
99
|
+
? require('@pulse-compute/crypto/provider').bindJavascriptEs256Signer()
|
|
100
|
+
: require('@pulse-compute/crypto/provider').bindJavascriptDigestMac(['HMAC-SHA256']))
|
|
101
|
+
: assertRuntimeBuiltin(cryptoProvider, jwtProvider);
|
|
92
102
|
const host = {
|
|
93
103
|
async captureWallClock() {
|
|
94
104
|
assertRequestActive(execution, jwtProvider);
|
|
@@ -118,7 +128,7 @@ function createNodeJavascriptJwtVerify(options = {}) {
|
|
|
118
128
|
return result;
|
|
119
129
|
};
|
|
120
130
|
}
|
|
121
|
-
const result = await jwtProvider.verifyJwtWithCrypto(
|
|
131
|
+
const result = await (signing ? jwtProvider.signJwtWithCrypto : jwtProvider.verifyJwtWithCrypto)(
|
|
122
132
|
input,
|
|
123
133
|
Object.freeze(host),
|
|
124
134
|
selectedCrypto
|
|
@@ -14,7 +14,7 @@ function statusForError(error) {
|
|
|
14
14
|
|
|
15
15
|
function writeSafeError(response, error) {
|
|
16
16
|
if (response.headersSent) {
|
|
17
|
-
if (!response.writableEnded) response.
|
|
17
|
+
if (!response.writableEnded) response.destroy(error);
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
20
|
response.statusCode = statusForError(error);
|
|
@@ -102,10 +102,15 @@ async function nodeRequestToWebRequest(request, options = {}) {
|
|
|
102
102
|
for (const [name, value] of headerPairs) headers.append(name, value);
|
|
103
103
|
const init = { method, headers };
|
|
104
104
|
if (bodyAllowed(method)) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
init.body = body;
|
|
105
|
+
if (options.bodyForwarding || options.bodyTransform) {
|
|
106
|
+
init.body = require('./incoming-body.js').nodeIncomingStream(request);
|
|
108
107
|
init.duplex = 'half';
|
|
108
|
+
} else {
|
|
109
|
+
const body = await readNodeRequestBody(request, options.maxRequestBodyBytes ?? options.maxBodyBytes, options.signal);
|
|
110
|
+
if (body.byteLength > 0) {
|
|
111
|
+
init.body = body;
|
|
112
|
+
init.duplex = 'half';
|
|
113
|
+
}
|
|
109
114
|
}
|
|
110
115
|
}
|
|
111
116
|
return Object.freeze({
|
|
@@ -150,6 +155,7 @@ async function writeWebResponseToNode(response, nodeResponse, options = {}) {
|
|
|
150
155
|
for (const group of groupHeaderPairs(runtimeHost.responseHeaderPairs(response))) {
|
|
151
156
|
nodeResponse.setHeader(group.name, group.values.length === 1 ? group.values[0] : [...group.values]);
|
|
152
157
|
}
|
|
158
|
+
if (options.closeConnection) nodeResponse.setHeader('connection', 'close');
|
|
153
159
|
|
|
154
160
|
const method = String(options.requestMethod || 'GET').toUpperCase();
|
|
155
161
|
if (method === 'HEAD' || !statusAllowsBody(response.status) || response.body == null) {
|
|
@@ -200,11 +206,21 @@ function createNodeJavascriptHandler(application, options = {}) {
|
|
|
200
206
|
maxKvValueDepth: options.maxKvValueDepth,
|
|
201
207
|
maxKvValueEntries: options.maxKvValueEntries
|
|
202
208
|
});
|
|
203
|
-
return async function pulseNodeJavascriptHandler(request, response) {
|
|
204
|
-
const
|
|
209
|
+
return async function pulseNodeJavascriptHandler(request, response, requestSignal) {
|
|
210
|
+
const connection = new AbortController();
|
|
211
|
+
const aborted = () => connection.abort(new Error('Node request disconnected.'));
|
|
212
|
+
const closed = () => { if (!response.writableFinished) aborted(); };
|
|
213
|
+
request.once('aborted', aborted); response.once?.('close', closed);
|
|
214
|
+
const signal = AbortSignal.any([options.signal, requestSignal, connection.signal].filter(Boolean));
|
|
215
|
+
const budget = runtimeHost.createRequestBudget({ ...options, signal });
|
|
216
|
+
let incomingBody, adapted, outputExecution;
|
|
205
217
|
try {
|
|
206
218
|
budget.check();
|
|
207
|
-
|
|
219
|
+
adapted = await budget.race(nodeRequestToWebRequest(request, { ...options, signal: budget.signal }));
|
|
220
|
+
if ((options.bodyForwarding || options.bodyTransform) && bodyAllowed(adapted.method)) response.setHeader('connection', 'close');
|
|
221
|
+
incomingBody = require('./incoming-body.js').createIncomingBody(adapted.request, {
|
|
222
|
+
...options, signal: budget.signal, responseWriterOwnsCompletion: true
|
|
223
|
+
});
|
|
208
224
|
const requestContext = Object.freeze({
|
|
209
225
|
request,
|
|
210
226
|
response,
|
|
@@ -237,7 +253,10 @@ function createNodeJavascriptHandler(application, options = {}) {
|
|
|
237
253
|
const activeApplication = options.getApplication
|
|
238
254
|
? runtimeHost.normalizeApplication(options.getApplication())
|
|
239
255
|
: normalizedApplication;
|
|
256
|
+
outputExecution = require('../runtime/generated-output.js').createGeneratedOutput(response, { ...options, requestBudget: budget, requestMethod: adapted.method });
|
|
240
257
|
const webResponse = await executeNodeJavascriptApplication(activeApplication, adapted.request, {
|
|
258
|
+
outputExecution,
|
|
259
|
+
incomingBody,
|
|
241
260
|
capabilities,
|
|
242
261
|
effectAdapter,
|
|
243
262
|
config,
|
|
@@ -275,7 +294,13 @@ function createNodeJavascriptHandler(application, options = {}) {
|
|
|
275
294
|
onEffectObservation: options.onEffectObservation,
|
|
276
295
|
onEffectSummary: options.onEffectSummary
|
|
277
296
|
});
|
|
278
|
-
|
|
297
|
+
if (incomingBody?.failure) throw incomingBody.failure;
|
|
298
|
+
if (outputExecution?.started) await outputExecution.finish();
|
|
299
|
+
else await writeWebResponseToNode(webResponse, response, {
|
|
300
|
+
requestMethod: adapted.method,
|
|
301
|
+
signal: incomingBody?.responseSignal || budget.signal, requestBudget: budget,
|
|
302
|
+
closeConnection: Boolean((options.bodyForwarding || options.bodyTransform) && bodyAllowed(adapted.method))
|
|
303
|
+
});
|
|
279
304
|
if (typeof options.onRequest === 'function') {
|
|
280
305
|
options.onRequest(Object.freeze({
|
|
281
306
|
method: adapted.method,
|
|
@@ -286,7 +311,15 @@ function createNodeJavascriptHandler(application, options = {}) {
|
|
|
286
311
|
}));
|
|
287
312
|
}
|
|
288
313
|
return webResponse;
|
|
289
|
-
} finally {
|
|
314
|
+
} finally {
|
|
315
|
+
outputExecution?.dispose();
|
|
316
|
+
await incomingBody?.close();
|
|
317
|
+
if ((options.bodyForwarding || options.bodyTransform) && !incomingBody && adapted?.request.body && !adapted.request.body.locked) {
|
|
318
|
+
void adapted.request.body.cancel().catch(() => {});
|
|
319
|
+
}
|
|
320
|
+
request.removeListener('aborted', aborted); response.removeListener?.('close', closed);
|
|
321
|
+
if (!options.requestBudget) budget.close();
|
|
322
|
+
}
|
|
290
323
|
};
|
|
291
324
|
}
|
|
292
325
|
|
|
@@ -49,7 +49,7 @@ function createNodeJavascriptPackageEffectCapabilities(options = {}) {
|
|
|
49
49
|
if (typeof gripBroadcast !== 'function') return unavailableGrip();
|
|
50
50
|
return gripBroadcast(effect.payload, execution);
|
|
51
51
|
}
|
|
52
|
-
if (effect && effect.contractId === 'pulse.jwt' && effect.operation
|
|
52
|
+
if (effect && effect.contractId === 'pulse.jwt' && ['verify', 'sign'].includes(effect.operation)) {
|
|
53
53
|
if (typeof jwtVerify !== 'function') return unavailable(effect);
|
|
54
54
|
return jwtVerify(effect, execution);
|
|
55
55
|
}
|
|
@@ -88,7 +88,12 @@ function nodeJavascriptProviderCapabilities(options) {
|
|
|
88
88
|
|
|
89
89
|
async function executeNodeJavascriptApplication(application, request, options = {}) {
|
|
90
90
|
const capabilities = nodeJavascriptProviderCapabilities(options);
|
|
91
|
-
|
|
91
|
+
const incomingBody = options.incomingBody || require('./incoming-body.js').createIncomingBody(request, options);
|
|
92
|
+
try {
|
|
93
|
+
options.outputExecution?.bindInput(incomingBody);
|
|
94
|
+
return await runtimeHost.executeApplication(assertNodeJavascriptApplication(application), request, {
|
|
95
|
+
outputExecution: options.outputExecution,
|
|
96
|
+
incomingBody,
|
|
92
97
|
capabilities,
|
|
93
98
|
effectAdapter: options.effectAdapter,
|
|
94
99
|
application: options.application,
|
|
@@ -118,7 +123,7 @@ async function executeNodeJavascriptApplication(application, request, options =
|
|
|
118
123
|
onJsonTrace: options.onJsonTrace,
|
|
119
124
|
onEffectObservation: options.onEffectObservation,
|
|
120
125
|
onEffectSummary: options.onEffectSummary
|
|
121
|
-
});
|
|
126
|
+
}); } finally { if (!options.incomingBody) await incomingBody?.close(); }
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
async function executeNodeJavascriptEvent(application, frame, options = {}) {
|
package/src/javascript/s3.js
CHANGED
|
@@ -5,17 +5,18 @@ const { readS3 } = require('../runtime/s3-reader.js');
|
|
|
5
5
|
|
|
6
6
|
function createNodeJavascriptS3(options, secretLookup) {
|
|
7
7
|
const s3 = normalizeNodeS3(options.s3 || options.bindings && options.bindings.s3);
|
|
8
|
-
return (effect, execution = {}) => {
|
|
8
|
+
return async (effect, execution = {}) => {
|
|
9
9
|
// This provider target explicitly selects Crypto's Web Crypto realization.
|
|
10
10
|
// Native execution supplies its own Wasm exports to the same Node transport.
|
|
11
11
|
let selected;
|
|
12
12
|
try { selected = bindJavascriptDigestMac(['SHA-256', 'HMAC-SHA256']); }
|
|
13
13
|
catch { selected = null; }
|
|
14
|
-
|
|
15
|
-
s3, signal: execution.signal, requestBudget: execution.requestBudget, registerRedactionValue: execution.registerRedactionValue,
|
|
14
|
+
const result = await readS3(effect, {
|
|
15
|
+
s3, signal: execution.signal, responseSignal: options.signal, requestBudget: execution.requestBudget, registerRedactionValue: execution.registerRedactionValue,
|
|
16
16
|
fetchImplementation: options.s3FetchImplementation || options.fetchImplementation, cryptoTarget: 'javascript',
|
|
17
17
|
cryptoVerifier: selected, cryptoRealization: selected && selected.realization
|
|
18
18
|
}, (name) => secretLookup(name, execution));
|
|
19
|
+
return effect.operation === 'getBody' ? require('@pulse-compute/runtime/host').markOpaqueResponse(result) : result;
|
|
19
20
|
};
|
|
20
21
|
}
|
|
21
22
|
module.exports = { createNodeJavascriptS3 };
|
|
@@ -10,8 +10,12 @@ function decision(id, status, reasonId, owner, required = true) {
|
|
|
10
10
|
|
|
11
11
|
function classifyNodeJavascriptCapability(id, options = {}) {
|
|
12
12
|
const capability = String(id);
|
|
13
|
+
if (capability === 'request.body.transform') return decision(capability, options.bodyTransform ? 'eligible' : 'blocked', options.bodyTransform ? 'node-body-transform-experimental' : 'node-body-transform-not-configured', 'provider-node');
|
|
14
|
+
if (capability === 'response.output') return decision(capability, options.generatedOutput ? 'eligible' : 'blocked', options.generatedOutput ? 'node-generated-output-experimental' : 'node-generated-output-not-configured', 'provider-node');
|
|
15
|
+
if (capability === 'request.body.forward' || capability === 'req.body') return decision(capability,
|
|
16
|
+
options.bodyForwarding ? 'eligible' : 'blocked', options.bodyForwarding ? 'node-incoming-body-forwarding' : 'node-incoming-body-forwarding-not-configured', 'provider-node');
|
|
13
17
|
if (capability === 'crypto.digestText') return decision(capability, 'eligible', 'node-javascript-crypto-sha256-runtime-builtin', 'provider-node');
|
|
14
|
-
if (['s3.head', 's3.getText', 's3.putText'].includes(capability)) return decision(capability, 'eligible', 'node-s3-bounded-origin-transport', 'provider-node');
|
|
18
|
+
if (['s3.head', 's3.getText', 's3.putText', 's3.getBody'].includes(capability)) return decision(capability, 'eligible', 'node-s3-bounded-origin-transport', 'provider-node');
|
|
15
19
|
if (isJavascriptCoreCapability(capability)) {
|
|
16
20
|
return decision(capability, 'eligible', 'node-router-context-parity', 'provider-node');
|
|
17
21
|
}
|
|
@@ -41,9 +45,11 @@ function classifyNodeJavascriptCapability(id, options = {}) {
|
|
|
41
45
|
return decision(capability, 'eligible', 'node-provider-wall-clock-authority', 'provider-node');
|
|
42
46
|
}
|
|
43
47
|
if (
|
|
44
|
-
capability === 'jwt.
|
|
48
|
+
capability === 'jwt.sign'
|
|
49
|
+
|| capability === 'jwt.verify'
|
|
45
50
|
|| capability === 'jwt.verify.hs256'
|
|
46
51
|
|| capability === 'jwt.verify.es256'
|
|
52
|
+
|| capability === 'jwt.verify.rs256'
|
|
47
53
|
) {
|
|
48
54
|
return decision(capability, 'eligible', 'node-javascript-jwt-crypto-runtime-builtin', 'provider-node');
|
|
49
55
|
}
|
|
@@ -77,8 +83,11 @@ function classifyNodeJavascriptCapability(id, options = {}) {
|
|
|
77
83
|
|
|
78
84
|
function classifyNodeJavascriptProviderRequirement(id, compilerCapabilities, options = {}) {
|
|
79
85
|
const requirement = String(id);
|
|
86
|
+
if (requirement === 'request.body.transform') return classifyNodeJavascriptCapability(requirement, options);
|
|
87
|
+
if (requirement === 'response.output') return classifyNodeJavascriptCapability(requirement, options);
|
|
88
|
+
if (requirement === 'request.body.forward') return classifyNodeJavascriptCapability(requirement, options);
|
|
80
89
|
if (requirement === 'crypto.digestText') return decision(requirement, 'eligible', 'node-javascript-crypto-sha256-runtime-builtin', 'provider-node');
|
|
81
|
-
if (['s3.head', 's3.getText', 's3.putText'].includes(requirement)) return decision(requirement, 'eligible', 'node-s3-bounded-origin-transport', 'provider-node');
|
|
90
|
+
if (['s3.head', 's3.getText', 's3.putText', 's3.getBody'].includes(requirement)) return decision(requirement, 'eligible', 'node-s3-bounded-origin-transport', 'provider-node');
|
|
82
91
|
if (requirement === 'request') {
|
|
83
92
|
const bodyRequired = compilerCapabilities.some((entry) => entry === 'request.json' || entry.startsWith('request.body'));
|
|
84
93
|
return bodyRequired
|
|
@@ -106,9 +115,11 @@ function classifyNodeJavascriptProviderRequirement(id, compilerCapabilities, opt
|
|
|
106
115
|
return decision(requirement, 'eligible', 'node-provider-wall-clock-authority', 'provider-node');
|
|
107
116
|
}
|
|
108
117
|
if (
|
|
109
|
-
requirement === 'jwt.
|
|
118
|
+
requirement === 'jwt.sign'
|
|
119
|
+
|| requirement === 'jwt.verify'
|
|
110
120
|
|| requirement === 'jwt.verify.hs256'
|
|
111
121
|
|| requirement === 'jwt.verify.es256'
|
|
122
|
+
|| requirement === 'jwt.verify.rs256'
|
|
112
123
|
) {
|
|
113
124
|
return decision(requirement, 'eligible', 'node-javascript-jwt-crypto-runtime-builtin', 'provider-node');
|
|
114
125
|
}
|
package/src/javascript/target.js
CHANGED
|
@@ -27,6 +27,11 @@ const NODE_JAVASCRIPT_TARGET_DESCRIPTOR = normalizeJavascriptTargetDescriptor({
|
|
|
27
27
|
realization: 'runtime-builtin',
|
|
28
28
|
implemented: true,
|
|
29
29
|
status: 'implemented-g3'
|
|
30
|
+
}, {
|
|
31
|
+
algorithm: 'RS256',
|
|
32
|
+
realization: 'runtime-builtin',
|
|
33
|
+
implemented: true,
|
|
34
|
+
status: 'implemented-rs256'
|
|
30
35
|
}]
|
|
31
36
|
}),
|
|
32
37
|
commands: { compile: true, inspect: true, doctor: true, build: true, test: true, dev: true }
|