@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/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.4/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,12 +3,17 @@
|
|
|
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',
|
|
11
14
|
'fetch',
|
|
15
|
+
'time.wall-clock',
|
|
16
|
+
'crypto.digestText',
|
|
12
17
|
'config.get',
|
|
13
18
|
'secret.get',
|
|
14
19
|
'kv.get',
|
|
@@ -19,15 +24,21 @@ const NODE_CANONICAL_PROVIDER_CAPABILITIES = Object.freeze([
|
|
|
19
24
|
'assets.lookup',
|
|
20
25
|
'grip.broadcast',
|
|
21
26
|
NODE_JWT_VERIFY_CAPABILITY,
|
|
27
|
+
'jwt.sign',
|
|
22
28
|
'opaque.pass-through'
|
|
23
29
|
]);
|
|
24
30
|
|
|
25
31
|
const NODE_CANONICAL_PROVIDER_LOWERING = Object.freeze({
|
|
26
32
|
request: 'node.http.request',
|
|
33
|
+
'request.body.forward': 'node.http.incoming-request-v1',
|
|
27
34
|
'response.json': 'node.http.response.json',
|
|
28
35
|
'response.text': 'node.http.response.text',
|
|
29
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',
|
|
30
39
|
fetch: 'node.fetch.dispatch',
|
|
40
|
+
'time.wall-clock': 'node.runtime.time.now',
|
|
41
|
+
'crypto.digestText': 'node.crypto.digest-text',
|
|
31
42
|
'config.get': 'node.runtime.config.get',
|
|
32
43
|
'secret.get': 'node.runtime.secret.get',
|
|
33
44
|
'kv.get': 'node.runtime.kv.get',
|
|
@@ -40,21 +51,26 @@ const NODE_CANONICAL_PROVIDER_LOWERING = Object.freeze({
|
|
|
40
51
|
's3.head': 'node.native.s3.head',
|
|
41
52
|
's3.getText': 'node.native.s3.getText',
|
|
42
53
|
's3.putText': 'node.native.s3.putText',
|
|
54
|
+
's3.getBody': 'node.s3.opaque-body',
|
|
43
55
|
'assets.lookup': 'node.runtime.assets.lookup',
|
|
44
56
|
'grip.broadcast': 'node.http.grip.publish',
|
|
45
57
|
[NODE_JWT_VERIFY_CAPABILITY]: 'node.native.jwt.verify',
|
|
58
|
+
'jwt.sign': 'node.native.jwt.sign',
|
|
46
59
|
'opaque.pass-through': 'node.http.response.stream'
|
|
47
60
|
});
|
|
48
61
|
|
|
49
62
|
const NODE_NATIVE_TARGET_CAPABILITIES = Object.freeze([
|
|
50
|
-
's3.head', 's3.getText', 's3.putText',
|
|
63
|
+
's3.head', 's3.getText', 's3.putText', 's3.getBody',
|
|
51
64
|
NODE_JWT_VERIFY_CAPABILITY,
|
|
65
|
+
'jwt.sign',
|
|
52
66
|
'jwt.verify.hs256',
|
|
53
67
|
'jwt.verify.es256',
|
|
68
|
+
'jwt.verify.rs256',
|
|
54
69
|
'secret.get',
|
|
55
70
|
'event.ingress',
|
|
56
71
|
'event.emit',
|
|
57
|
-
'time.wall-clock'
|
|
72
|
+
'time.wall-clock',
|
|
73
|
+
'crypto.digestText'
|
|
58
74
|
]);
|
|
59
75
|
|
|
60
76
|
module.exports = Object.freeze({
|
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,
|
|
@@ -29,6 +32,10 @@ function createNodeJavascriptFetchCapability(options = {}) {
|
|
|
29
32
|
signal: execution.signal,
|
|
30
33
|
redirect: 'follow'
|
|
31
34
|
});
|
|
35
|
+
if (execution.signal?.aborted) {
|
|
36
|
+
if (response instanceof Response && response.body) void response.body.cancel(execution.signal.reason).catch(() => {});
|
|
37
|
+
throw execution.signal.reason;
|
|
38
|
+
}
|
|
32
39
|
if (!(response instanceof Response) && !runtimeHost.isPulseFetchResponse(response)) {
|
|
33
40
|
throw fetchError(
|
|
34
41
|
'PULSE_FETCH_RESPONSE_INVALID',
|
|
@@ -132,6 +139,14 @@ function createNodeJavascriptFixtureFetch(fetches = {}, fallback, options = {})
|
|
|
132
139
|
}
|
|
133
140
|
await delayWithSignal(fixture.delayMs, init.signal);
|
|
134
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
|
+
|
|
135
150
|
const status = Number(fixture.status || 200);
|
|
136
151
|
let body = null;
|
|
137
152
|
let defaultContentType;
|
|
@@ -165,7 +180,7 @@ function createNodeJavascriptFixtureFetch(fetches = {}, fallback, options = {})
|
|
|
165
180
|
&& status !== 304
|
|
166
181
|
&& !(status >= 100 && status < 200);
|
|
167
182
|
const response = new Response(bodyAllowed ? body : null, { status, headers });
|
|
168
|
-
if (options.rawResponse === true) return response;
|
|
183
|
+
if (options.rawResponse === true || init.duplex === 'half') return response;
|
|
169
184
|
return runtimeHost.createOpaqueFetchResponse({ response, headers: headerPairs });
|
|
170
185
|
};
|
|
171
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
|
|
@@ -6,6 +6,7 @@ const { createNodeJavascriptHandler } = require('./node-adapter.js');
|
|
|
6
6
|
const NODE_JAVASCRIPT_LIFECYCLE_VERSION = 'pulse.node-javascript-lifecycle.v1';
|
|
7
7
|
|
|
8
8
|
function statusForError(error) {
|
|
9
|
+
if (['PULSE_REQUEST_DEADLINE_EXCEEDED', 'PULSE_REQUEST_CLOCK_INVALID'].includes(error?.code)) return 504;
|
|
9
10
|
if (error && error.code === 'PULSE_REQUEST_BODY_TOO_LARGE') return 413;
|
|
10
11
|
if (error && error.code === 'PULSE_NODE_REQUEST_READ_FAILED') return 400;
|
|
11
12
|
return 500;
|
|
@@ -13,12 +14,13 @@ function statusForError(error) {
|
|
|
13
14
|
|
|
14
15
|
function writeSafeError(response, error) {
|
|
15
16
|
if (response.headersSent) {
|
|
16
|
-
if (!response.writableEnded) response.
|
|
17
|
+
if (!response.writableEnded) response.destroy(error);
|
|
17
18
|
return;
|
|
18
19
|
}
|
|
19
20
|
response.statusCode = statusForError(error);
|
|
20
21
|
response.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
21
|
-
|
|
22
|
+
if (response.statusCode === 504) response.setHeader('connection', 'close');
|
|
23
|
+
response.end(response.statusCode === 504 ? 'Gateway Timeout' : response.statusCode === 413 ? 'Payload Too Large' : (response.statusCode === 400 ? 'Bad Request' : 'Internal Server Error'));
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
function createNodeJavascriptServer(application, options = {}) {
|