@pulse-compute/provider-node 1.0.0-beta.3 → 1.0.0-beta.5
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 +3 -3
- package/package.json +9 -9
- package/src/capabilities.js +6 -1
- package/src/javascript/fetch-adapter.js +4 -0
- package/src/javascript/lifecycle.js +3 -1
- package/src/javascript/node-adapter.js +135 -116
- package/src/javascript/package-effects.js +4 -1
- package/src/javascript/runtime-host.js +6 -1
- package/src/javascript/s3.js +1 -1
- package/src/javascript/target-support-policy.js +2 -0
- package/src/javascript/test-runtime.js +1 -0
- package/src/runtime/canonical-api-runtime.js +31 -4
- package/src/runtime/s3-reader.js +4 -1
- package/src/toolchain.js +5 -4
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
> **Stability:** The listed built-in toolchain entry is synchronized with the Beta bootstrap contract; other canonical Node provider interfaces remain internal.<br>
|
|
9
9
|
> **npm:** [`@pulse-compute/provider-node`](https://www.npmjs.com/package/@pulse-compute/provider-node)<br>
|
|
10
10
|
> **Canonical replacement:** `provider: 'node' through @pulse-compute/cli`<br>
|
|
11
|
-
> **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.
|
|
11
|
+
> **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.5/packages/implementation-packages/)
|
|
12
12
|
>
|
|
13
|
-
> This release-status block is generated from the synchronized `Pulse 1.0.0-beta.
|
|
13
|
+
> This release-status block is generated from the synchronized `Pulse 1.0.0-beta.5` package policy.
|
|
14
14
|
<!-- pulse-package-status:end -->
|
|
15
15
|
|
|
16
16
|
Canonical Node provider implementation used internally when a project selects `provider: 'node'`.
|
|
@@ -60,4 +60,4 @@ The package exposes focused compiler and runtime modules for the synchronized
|
|
|
60
60
|
repository, including `runtime/canonical-api-runtime`. Those exports are
|
|
61
61
|
implementation interfaces, not application-author APIs.
|
|
62
62
|
|
|
63
|
-
See [Contracts and providers](https://pulsecompute.io/v1.0.0-beta.
|
|
63
|
+
See [Contracts and providers](https://pulsecompute.io/v1.0.0-beta.5/concepts/contracts-and-providers/) and [Implementation packages](https://pulsecompute.io/v1.0.0-beta.5/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.5",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": "^22.14.0 || ^24.0.0"
|
|
@@ -42,13 +42,13 @@
|
|
|
42
42
|
"./javascript/source-package": "./src/javascript/source-package.js"
|
|
43
43
|
},
|
|
44
44
|
"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.
|
|
45
|
+
"@pulse-compute/crypto": "1.0.0-beta.5",
|
|
46
|
+
"@pulse-compute/jwt": "1.0.0-beta.5",
|
|
47
|
+
"@pulse-compute/runtime": "1.0.0-beta.5",
|
|
48
|
+
"@pulse-compute/s3": "1.0.0-beta.5",
|
|
49
|
+
"@pulse-compute/wasm-build-support": "1.0.0-beta.5",
|
|
50
|
+
"@pulse-compute/wasm-contracts": "1.0.0-beta.5",
|
|
51
|
+
"@pulse-compute/wasm-host-runtime": "1.0.0-beta.5"
|
|
52
52
|
},
|
|
53
53
|
"publishConfig": {
|
|
54
54
|
"access": "public"
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"url": "git+https://github.com/pulse-compute/pulse.git",
|
|
65
65
|
"directory": "packages/provider-node"
|
|
66
66
|
},
|
|
67
|
-
"homepage": "https://pulsecompute.io/v1.0.0-beta.
|
|
67
|
+
"homepage": "https://pulsecompute.io/v1.0.0-beta.5/packages/implementation-packages/",
|
|
68
68
|
"bugs": {
|
|
69
69
|
"url": "https://github.com/pulse-compute/pulse/issues"
|
|
70
70
|
}
|
package/src/capabilities.js
CHANGED
|
@@ -9,6 +9,8 @@ const NODE_CANONICAL_PROVIDER_CAPABILITIES = Object.freeze([
|
|
|
9
9
|
'response.text',
|
|
10
10
|
'response.custom',
|
|
11
11
|
'fetch',
|
|
12
|
+
'time.wall-clock',
|
|
13
|
+
'crypto.digestText',
|
|
12
14
|
'config.get',
|
|
13
15
|
'secret.get',
|
|
14
16
|
'kv.get',
|
|
@@ -28,6 +30,8 @@ const NODE_CANONICAL_PROVIDER_LOWERING = Object.freeze({
|
|
|
28
30
|
'response.text': 'node.http.response.text',
|
|
29
31
|
'response.custom': 'node.http.response.custom',
|
|
30
32
|
fetch: 'node.fetch.dispatch',
|
|
33
|
+
'time.wall-clock': 'node.runtime.time.now',
|
|
34
|
+
'crypto.digestText': 'node.crypto.digest-text',
|
|
31
35
|
'config.get': 'node.runtime.config.get',
|
|
32
36
|
'secret.get': 'node.runtime.secret.get',
|
|
33
37
|
'kv.get': 'node.runtime.kv.get',
|
|
@@ -54,7 +58,8 @@ const NODE_NATIVE_TARGET_CAPABILITIES = Object.freeze([
|
|
|
54
58
|
'secret.get',
|
|
55
59
|
'event.ingress',
|
|
56
60
|
'event.emit',
|
|
57
|
-
'time.wall-clock'
|
|
61
|
+
'time.wall-clock',
|
|
62
|
+
'crypto.digestText'
|
|
58
63
|
]);
|
|
59
64
|
|
|
60
65
|
module.exports = Object.freeze({
|
|
@@ -29,6 +29,10 @@ function createNodeJavascriptFetchCapability(options = {}) {
|
|
|
29
29
|
signal: execution.signal,
|
|
30
30
|
redirect: 'follow'
|
|
31
31
|
});
|
|
32
|
+
if (execution.signal?.aborted) {
|
|
33
|
+
if (response instanceof Response && response.body) void response.body.cancel(execution.signal.reason).catch(() => {});
|
|
34
|
+
throw execution.signal.reason;
|
|
35
|
+
}
|
|
32
36
|
if (!(response instanceof Response) && !runtimeHost.isPulseFetchResponse(response)) {
|
|
33
37
|
throw fetchError(
|
|
34
38
|
'PULSE_FETCH_RESPONSE_INVALID',
|
|
@@ -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;
|
|
@@ -18,7 +19,8 @@ function writeSafeError(response, error) {
|
|
|
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 = {}) {
|
|
@@ -67,33 +67,31 @@ function bodyAllowed(method) {
|
|
|
67
67
|
return normalized !== 'GET' && normalized !== 'HEAD';
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
async function readNodeRequestBody(request, maxBytes = DEFAULT_MAX_BODY_BYTES) {
|
|
70
|
+
async function readNodeRequestBody(request, maxBytes = DEFAULT_MAX_BODY_BYTES, signal) {
|
|
71
71
|
const limit = Number(maxBytes);
|
|
72
72
|
if (!Number.isSafeInteger(limit) || limit <= 0) throw new TypeError('Pulse Node maxBodyBytes must be a positive safe integer.');
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const chunks = [];
|
|
75
|
+
let bytes = 0;
|
|
76
|
+
function cleanup() {
|
|
77
|
+
for (const [event, fn] of [['data', data], ['end', end], ['error', error], ['aborted', aborted]]) request.removeListener(event, fn);
|
|
78
|
+
signal?.removeEventListener('abort', cancel);
|
|
79
|
+
}
|
|
80
|
+
function fail(cause) { cleanup(); chunks.length = 0; request.once('error', () => {}); request.resume(); reject(cause); }
|
|
81
|
+
function cancel() { fail(signal.reason); }
|
|
82
|
+
function error(cause) { fail(new PulseNodeJavascriptRequestError('PULSE_NODE_REQUEST_READ_FAILED', 'Pulse could not read the Node request body.', { causeName: cause?.name })); }
|
|
83
|
+
function aborted() { error(new Error('Request aborted')); }
|
|
84
|
+
function data(chunk) {
|
|
77
85
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
78
86
|
bytes += buffer.byteLength;
|
|
79
|
-
if (bytes > limit) {
|
|
80
|
-
throw new PulseNodeJavascriptRequestError(
|
|
81
|
-
'PULSE_REQUEST_BODY_TOO_LARGE',
|
|
82
|
-
`Request body exceeds ${limit} bytes.`,
|
|
83
|
-
{ maxBytes: limit, bytes }
|
|
84
|
-
);
|
|
85
|
-
}
|
|
87
|
+
if (bytes > limit) return fail(new PulseNodeJavascriptRequestError('PULSE_REQUEST_BODY_TOO_LARGE', `Request body exceeds ${limit} bytes.`, { maxBytes: limit, bytes }));
|
|
86
88
|
chunks.push(buffer);
|
|
87
89
|
}
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
{ causeName: error && error.name }
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
return Buffer.concat(chunks);
|
|
90
|
+
function end() { cleanup(); resolve(Buffer.concat(chunks)); }
|
|
91
|
+
if (signal?.aborted) return cancel();
|
|
92
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
93
|
+
request.on('data', data); request.once('end', end); request.once('error', error); request.once('aborted', aborted);
|
|
94
|
+
});
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
async function nodeRequestToWebRequest(request, options = {}) {
|
|
@@ -104,7 +102,7 @@ async function nodeRequestToWebRequest(request, options = {}) {
|
|
|
104
102
|
for (const [name, value] of headerPairs) headers.append(name, value);
|
|
105
103
|
const init = { method, headers };
|
|
106
104
|
if (bodyAllowed(method)) {
|
|
107
|
-
const body = await readNodeRequestBody(request, options.maxRequestBodyBytes ?? options.maxBodyBytes);
|
|
105
|
+
const body = await readNodeRequestBody(request, options.maxRequestBodyBytes ?? options.maxBodyBytes, options.signal);
|
|
108
106
|
if (body.byteLength > 0) {
|
|
109
107
|
init.body = body;
|
|
110
108
|
init.duplex = 'half';
|
|
@@ -144,25 +142,39 @@ async function writeWebResponseToNode(response, nodeResponse, options = {}) {
|
|
|
144
142
|
if (!(response instanceof Response)) throw new TypeError('Pulse Node response adaptation requires a Web Response.');
|
|
145
143
|
if (!nodeResponse || typeof nodeResponse.end !== 'function') throw new TypeError('Pulse Node response adaptation requires a ServerResponse-like object.');
|
|
146
144
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
nodeResponse.
|
|
151
|
-
|
|
145
|
+
const check = () => { options.requestBudget?.check(); options.signal?.throwIfAborted(); };
|
|
146
|
+
try {
|
|
147
|
+
check();
|
|
148
|
+
nodeResponse.statusCode = response.status;
|
|
149
|
+
if (response.statusText && typeof nodeResponse.statusMessage === 'string') nodeResponse.statusMessage = response.statusText;
|
|
150
|
+
for (const group of groupHeaderPairs(runtimeHost.responseHeaderPairs(response))) {
|
|
151
|
+
nodeResponse.setHeader(group.name, group.values.length === 1 ? group.values[0] : [...group.values]);
|
|
152
|
+
}
|
|
152
153
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
const method = String(options.requestMethod || 'GET').toUpperCase();
|
|
155
|
+
if (method === 'HEAD' || !statusAllowsBody(response.status) || response.body == null) {
|
|
156
|
+
check();
|
|
157
|
+
nodeResponse.end();
|
|
158
|
+
return response;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (typeof Readable.fromWeb === 'function') {
|
|
162
|
+
check();
|
|
163
|
+
await pipeline(Readable.fromWeb(response.body), nodeResponse, { signal: options.signal });
|
|
164
|
+
check();
|
|
165
|
+
return response;
|
|
166
|
+
}
|
|
158
167
|
|
|
159
|
-
|
|
160
|
-
|
|
168
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
169
|
+
check();
|
|
170
|
+
nodeResponse.end(body);
|
|
161
171
|
return response;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (response.body && !response.body.locked) void response.body.cancel(error).catch(() => {});
|
|
174
|
+
// Preserve the deadline diagnostic instead of pipeline's generic AbortError.
|
|
175
|
+
check();
|
|
176
|
+
throw error;
|
|
162
177
|
}
|
|
163
|
-
|
|
164
|
-
nodeResponse.end(Buffer.from(await response.arrayBuffer()));
|
|
165
|
-
return response;
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
async function resolveRequestOption(value, context) {
|
|
@@ -189,85 +201,92 @@ function createNodeJavascriptHandler(application, options = {}) {
|
|
|
189
201
|
maxKvValueEntries: options.maxKvValueEntries
|
|
190
202
|
});
|
|
191
203
|
return async function pulseNodeJavascriptHandler(request, response) {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
204
|
+
const budget = runtimeHost.createRequestBudget(options);
|
|
205
|
+
try {
|
|
206
|
+
budget.check();
|
|
207
|
+
const adapted = await budget.race(nodeRequestToWebRequest(request, { ...options, signal: budget.signal }));
|
|
208
|
+
const requestContext = Object.freeze({
|
|
209
|
+
request,
|
|
210
|
+
response,
|
|
211
|
+
webRequest: adapted.request,
|
|
212
|
+
signal: budget.signal,
|
|
213
|
+
headerPairs: adapted.headerPairs
|
|
214
|
+
});
|
|
215
|
+
const resolve = value => { budget.check(); return budget.race(resolveRequestOption(value, requestContext)); };
|
|
216
|
+
const requestedCapabilities = await resolve(options.capabilities);
|
|
217
|
+
const effectAdapter = await resolve(options.effectAdapter);
|
|
218
|
+
const schemaCodecs = await resolve(options.schemaCodecs);
|
|
219
|
+
const config = hasDynamicBindings ? await resolve(options.config) : options.config;
|
|
220
|
+
const secrets = hasDynamicBindings ? await resolve(options.secrets) : options.secrets;
|
|
221
|
+
const kv = hasDynamicBindings ? await resolve(options.kv) : options.kv;
|
|
222
|
+
const bindings = staticBindings || createNodeJavascriptBindingCapabilities({
|
|
223
|
+
config, secrets, kv,
|
|
224
|
+
kvReference: options.kvReference,
|
|
225
|
+
maxBindingNameBytes: options.maxBindingNameBytes,
|
|
226
|
+
maxBindingValueBytes: options.maxBindingValueBytes,
|
|
227
|
+
maxKvNamespaceBytes: options.maxKvNamespaceBytes,
|
|
228
|
+
maxKvKeyBytes: options.maxKvKeyBytes,
|
|
229
|
+
maxKvValueBytes: options.maxKvValueBytes,
|
|
230
|
+
maxKvValueDepth: options.maxKvValueDepth,
|
|
231
|
+
maxKvValueEntries: options.maxKvValueEntries
|
|
232
|
+
});
|
|
233
|
+
const capabilities = effectAdapter === undefined
|
|
234
|
+
? withNodeBindingCapabilities(requestedCapabilities, { bindings })
|
|
235
|
+
: requestedCapabilities;
|
|
236
|
+
const started = process.hrtime.bigint();
|
|
237
|
+
const activeApplication = options.getApplication
|
|
238
|
+
? runtimeHost.normalizeApplication(options.getApplication())
|
|
239
|
+
: normalizedApplication;
|
|
240
|
+
const webResponse = await executeNodeJavascriptApplication(activeApplication, adapted.request, {
|
|
241
|
+
capabilities,
|
|
242
|
+
effectAdapter,
|
|
243
|
+
config,
|
|
244
|
+
secrets,
|
|
245
|
+
kv,
|
|
246
|
+
application: options.application,
|
|
247
|
+
requestHeaders: adapted.headerPairs,
|
|
248
|
+
signal: budget.signal,
|
|
249
|
+
requestBudget: budget, requestClock: options.requestClock,
|
|
250
|
+
fetchImplementation: options.fetchImplementation,
|
|
251
|
+
s3: options.s3 || options.bindings && options.bindings.s3,
|
|
252
|
+
s3FetchImplementation: options.s3FetchImplementation,
|
|
253
|
+
assetsLookup: options.assetsLookup,
|
|
254
|
+
gripBroadcast: options.gripBroadcast,
|
|
255
|
+
jwtCaptureWallClock: options.jwtCaptureWallClock,
|
|
256
|
+
assets: options.assets,
|
|
257
|
+
grip: options.grip,
|
|
258
|
+
maxEffects: options.maxEffects,
|
|
259
|
+
kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
|
|
260
|
+
maxRequestBodyBytes: options.maxRequestBodyBytes ?? options.maxBodyBytes,
|
|
261
|
+
maxFetchBodyBytes: options.maxFetchBodyBytes,
|
|
262
|
+
maxStructuredBodyBytes: options.maxStructuredBodyBytes,
|
|
263
|
+
maxBindingNameBytes: options.maxBindingNameBytes,
|
|
264
|
+
maxBindingValueBytes: options.maxBindingValueBytes,
|
|
265
|
+
maxKvNamespaceBytes: options.maxKvNamespaceBytes,
|
|
266
|
+
maxKvKeyBytes: options.maxKvKeyBytes,
|
|
267
|
+
maxKvValueBytes: options.maxKvValueBytes,
|
|
268
|
+
maxKvValueDepth: options.maxKvValueDepth,
|
|
269
|
+
maxKvValueEntries: options.maxKvValueEntries,
|
|
270
|
+
schemaCodecs,
|
|
271
|
+
strict: options.strict === true,
|
|
272
|
+
provider: options.provider || 'node',
|
|
273
|
+
redactionValues: options.redactionValues,
|
|
274
|
+
onJsonTrace: options.onJsonTrace,
|
|
275
|
+
onEffectObservation: options.onEffectObservation,
|
|
276
|
+
onEffectSummary: options.onEffectSummary
|
|
277
|
+
});
|
|
278
|
+
await writeWebResponseToNode(webResponse, response, { requestMethod: adapted.method, signal: budget.signal, requestBudget: budget });
|
|
279
|
+
if (typeof options.onRequest === 'function') {
|
|
280
|
+
options.onRequest(Object.freeze({
|
|
281
|
+
method: adapted.method,
|
|
282
|
+
url: adapted.request.url,
|
|
283
|
+
path: new URL(adapted.request.url).pathname,
|
|
284
|
+
status: webResponse.status,
|
|
285
|
+
durationMs: Number(process.hrtime.bigint() - started) / 1e6
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
return webResponse;
|
|
289
|
+
} finally { if (!options.requestBudget) budget.close(); }
|
|
271
290
|
};
|
|
272
291
|
}
|
|
273
292
|
|
|
@@ -30,6 +30,7 @@ function unavailable(effect) {
|
|
|
30
30
|
function createNodeJavascriptPackageEffectCapabilities(options = {}) {
|
|
31
31
|
const assetsLookup = options.assetsLookup || (options.assets && options.assets.lookup);
|
|
32
32
|
const gripBroadcast = options.gripBroadcast || (options.grip && options.grip.broadcast);
|
|
33
|
+
const digestText = require('@pulse-compute/crypto/provider').createJavascriptTextDigest(options);
|
|
33
34
|
const jwtVerify = options.jwtVerify || (options.jwt && options.jwt.verify);
|
|
34
35
|
const s3 = options.s3;
|
|
35
36
|
return Object.freeze({
|
|
@@ -39,6 +40,7 @@ function createNodeJavascriptPackageEffectCapabilities(options = {}) {
|
|
|
39
40
|
if (typeof s3 !== 'function') return unavailable(effect);
|
|
40
41
|
return s3(effect, execution);
|
|
41
42
|
}
|
|
43
|
+
if (effect && effect.contractId === 'pulse.crypto' && effect.operation === 'digestText') return digestText(effect, execution);
|
|
42
44
|
if (effect && effect.contractId === 'pulse.assets' && effect.operation === 'lookup') {
|
|
43
45
|
if (typeof assetsLookup !== 'function') return unavailable(effect);
|
|
44
46
|
return assetsLookup(effect.payload, execution);
|
|
@@ -63,7 +65,8 @@ function withNodePackageEffectCapabilities(capabilities, options = {}) {
|
|
|
63
65
|
...base,
|
|
64
66
|
effect(effect, execution) {
|
|
65
67
|
if (effect && (
|
|
66
|
-
effect.contractId === 'pulse.
|
|
68
|
+
effect.contractId === 'pulse.crypto'
|
|
69
|
+
|| effect.contractId === 'pulse.assets'
|
|
67
70
|
|| effect.contractId === 'pulse.grip'
|
|
68
71
|
|| effect.contractId === 'pulse.jwt'
|
|
69
72
|
|| effect.contractId === 'pulse.s3'
|
|
@@ -44,7 +44,10 @@ function nodeJavascriptProviderCapabilities(options) {
|
|
|
44
44
|
maxKvValueDepth: options.maxKvValueDepth,
|
|
45
45
|
maxKvValueEntries: options.maxKvValueEntries
|
|
46
46
|
};
|
|
47
|
-
const bindingCapabilities =
|
|
47
|
+
const bindingCapabilities = Object.freeze({
|
|
48
|
+
time: () => runtimeHost.readWallTime(options.wallClock === undefined ? () => Date.now() : options.wallClock),
|
|
49
|
+
...withNodeBindingCapabilities(options.capabilities, bindingOptions)
|
|
50
|
+
});
|
|
48
51
|
const configuredGripBroadcast = options.gripBroadcast
|
|
49
52
|
|| (options.grip && options.grip.broadcast)
|
|
50
53
|
|| (options.grip && options.grip.publishEndpoint
|
|
@@ -67,6 +70,7 @@ function nodeJavascriptProviderCapabilities(options) {
|
|
|
67
70
|
assetsLookup: configuredAssetsLookup,
|
|
68
71
|
gripBroadcast: configuredGripBroadcast,
|
|
69
72
|
jwtVerify: configuredJwtVerify,
|
|
73
|
+
digestSubtle: options.digestSubtle,
|
|
70
74
|
s3: createNodeJavascriptS3(options, bindingCapabilities.secret),
|
|
71
75
|
assets: options.assets,
|
|
72
76
|
grip: options.grip
|
|
@@ -90,6 +94,7 @@ async function executeNodeJavascriptApplication(application, request, options =
|
|
|
90
94
|
application: options.application,
|
|
91
95
|
requestHeaders: options.requestHeaders,
|
|
92
96
|
signal: options.signal,
|
|
97
|
+
maxDurationMs: options.maxDurationMs, requestBudget: options.requestBudget, requestClock: options.requestClock,
|
|
93
98
|
kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
|
|
94
99
|
maxEffects: options.maxEffects,
|
|
95
100
|
maxRequestBodyBytes: options.maxRequestBodyBytes,
|
package/src/javascript/s3.js
CHANGED
|
@@ -12,7 +12,7 @@ function createNodeJavascriptS3(options, secretLookup) {
|
|
|
12
12
|
try { selected = bindJavascriptDigestMac(['SHA-256', 'HMAC-SHA256']); }
|
|
13
13
|
catch { selected = null; }
|
|
14
14
|
return readS3(effect, {
|
|
15
|
-
s3, signal: execution.signal, registerRedactionValue: execution.registerRedactionValue,
|
|
15
|
+
s3, signal: execution.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));
|
|
@@ -10,6 +10,7 @@ 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 === 'crypto.digestText') return decision(capability, 'eligible', 'node-javascript-crypto-sha256-runtime-builtin', 'provider-node');
|
|
13
14
|
if (['s3.head', 's3.getText', 's3.putText'].includes(capability)) return decision(capability, 'eligible', 'node-s3-bounded-origin-transport', 'provider-node');
|
|
14
15
|
if (isJavascriptCoreCapability(capability)) {
|
|
15
16
|
return decision(capability, 'eligible', 'node-router-context-parity', 'provider-node');
|
|
@@ -76,6 +77,7 @@ function classifyNodeJavascriptCapability(id, options = {}) {
|
|
|
76
77
|
|
|
77
78
|
function classifyNodeJavascriptProviderRequirement(id, compilerCapabilities, options = {}) {
|
|
78
79
|
const requirement = String(id);
|
|
80
|
+
if (requirement === 'crypto.digestText') return decision(requirement, 'eligible', 'node-javascript-crypto-sha256-runtime-builtin', 'provider-node');
|
|
79
81
|
if (['s3.head', 's3.getText', 's3.putText'].includes(requirement)) return decision(requirement, 'eligible', 'node-s3-bounded-origin-transport', 'provider-node');
|
|
80
82
|
if (requirement === 'request') {
|
|
81
83
|
const bodyRequired = compilerCapabilities.some((entry) => entry === 'request.json' || entry.startsWith('request.body'));
|
|
@@ -60,6 +60,7 @@ async function executeNodeJavascriptTestCase(application, testCase, options = {}
|
|
|
60
60
|
kv: testCase.kv || {},
|
|
61
61
|
application: options.application,
|
|
62
62
|
requestHeaders: adapted.headerPairs,
|
|
63
|
+
maxDurationMs: options.maxDurationMs, requestClock: options.requestClock, requestBudget: options.requestBudget, signal: options.signal,
|
|
63
64
|
maxEffects: options.maxEffects,
|
|
64
65
|
maxRequestBodyBytes: options.maxRequestBodyBytes ?? options.maxBodyBytes ?? testCase.maxBodyBytes,
|
|
65
66
|
maxFetchBodyBytes: options.maxFetchBodyBytes ?? testCase.maxBodyBytes,
|
|
@@ -198,7 +198,7 @@ function responseIsStructured(method, headers, status = 200) {
|
|
|
198
198
|
|| contentType.includes('application/x-www-form-urlencoded');
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
async function readStructuredResponseText(response, maxBytes) {
|
|
201
|
+
async function readStructuredResponseText(response, maxBytes, signal) {
|
|
202
202
|
const limit = Number.isSafeInteger(Number(maxBytes)) && Number(maxBytes) > 0 ? Number(maxBytes) : 65_536;
|
|
203
203
|
const declared = Number(response.headers && response.headers.get && response.headers.get('content-length'));
|
|
204
204
|
if (Number.isSafeInteger(declared) && declared > limit) {
|
|
@@ -206,11 +206,16 @@ async function readStructuredResponseText(response, maxBytes) {
|
|
|
206
206
|
}
|
|
207
207
|
if (!response.body) return '';
|
|
208
208
|
const reader = response.body.getReader();
|
|
209
|
+
const cancel = () => { Promise.resolve(reader.cancel(signal.reason)).catch(() => {}); };
|
|
210
|
+
if (signal?.aborted) cancel();
|
|
211
|
+
else signal?.addEventListener('abort', cancel, { once: true });
|
|
209
212
|
const chunks = [];
|
|
210
213
|
let bytes = 0;
|
|
211
214
|
try {
|
|
212
215
|
while (true) {
|
|
216
|
+
signal?.throwIfAborted();
|
|
213
217
|
const result = await reader.read();
|
|
218
|
+
signal?.throwIfAborted();
|
|
214
219
|
if (result.done) break;
|
|
215
220
|
const chunk = result.value instanceof Uint8Array ? result.value : new Uint8Array(result.value || 0);
|
|
216
221
|
bytes += chunk.byteLength;
|
|
@@ -221,6 +226,7 @@ async function readStructuredResponseText(response, maxBytes) {
|
|
|
221
226
|
chunks.push(chunk);
|
|
222
227
|
}
|
|
223
228
|
} finally {
|
|
229
|
+
signal?.removeEventListener('abort', cancel);
|
|
224
230
|
try { reader.releaseLock(); } catch (_) { /* already released */ }
|
|
225
231
|
}
|
|
226
232
|
const output = new Uint8Array(bytes);
|
|
@@ -234,9 +240,13 @@ async function readStructuredResponseText(response, maxBytes) {
|
|
|
234
240
|
|
|
235
241
|
async function executeLiveFetch(normalized, fetchImplementation, options = {}) {
|
|
236
242
|
const timeoutMs = Number(normalized.init.timeoutMs);
|
|
237
|
-
const controller =
|
|
238
|
-
const
|
|
243
|
+
const controller = new AbortController();
|
|
244
|
+
const cancel = () => controller.abort(options.signal.reason);
|
|
245
|
+
if (options.signal?.aborted) cancel();
|
|
246
|
+
else options.signal?.addEventListener('abort', cancel, { once: true });
|
|
247
|
+
const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? setTimeout(() => controller.abort(new Error(`Fetch timed out after ${timeoutMs}ms.`)), timeoutMs) : undefined;
|
|
239
248
|
try {
|
|
249
|
+
controller.signal.throwIfAborted();
|
|
240
250
|
const response = await fetchImplementation(normalized.parts.url, {
|
|
241
251
|
method: normalized.init.method,
|
|
242
252
|
headers: normalized.init.headers,
|
|
@@ -244,6 +254,10 @@ async function executeLiveFetch(normalized, fetchImplementation, options = {}) {
|
|
|
244
254
|
signal: controller && controller.signal,
|
|
245
255
|
redirect: 'follow'
|
|
246
256
|
});
|
|
257
|
+
if (controller.signal.aborted) {
|
|
258
|
+
if (response.body) void response.body.cancel(controller.signal.reason).catch(() => {});
|
|
259
|
+
throw controller.signal.reason;
|
|
260
|
+
}
|
|
247
261
|
const headers = responseHeaders(response);
|
|
248
262
|
const responseMode = normalized.responseMode || 'auto';
|
|
249
263
|
const structured = responseMode === 'structured'
|
|
@@ -251,12 +265,22 @@ async function executeLiveFetch(normalized, fetchImplementation, options = {}) {
|
|
|
251
265
|
if (structured) {
|
|
252
266
|
const body = normalized.init.method === 'HEAD' || !responseStatusAllowsBody(response.status)
|
|
253
267
|
? ''
|
|
254
|
-
: await readStructuredResponseText(response, options.maxFetchBodyBytes || options.maxBodyBytes);
|
|
268
|
+
: await readStructuredResponseText(response, options.maxFetchBodyBytes || options.maxBodyBytes, controller.signal);
|
|
255
269
|
return { status: response.status, kind: 'text', headers, body };
|
|
256
270
|
}
|
|
257
271
|
const bodyStream = response.body && typeof Readable.fromWeb === 'function' ? Readable.fromWeb(response.body) : response.body;
|
|
272
|
+
if (bodyStream && options.requestBudget) {
|
|
273
|
+
// Until normal handoff, the request still owns an opaque result. A late
|
|
274
|
+
// response after expiry must be disposed without an unhandled error.
|
|
275
|
+
if (typeof bodyStream.on === 'function') bodyStream.on('error', () => {});
|
|
276
|
+
options.requestBudget.onAbort(() => {
|
|
277
|
+
if (typeof bodyStream.destroy === 'function') bodyStream.destroy(options.requestBudget.signal.reason);
|
|
278
|
+
else if (!bodyStream.locked) void bodyStream.cancel(options.requestBudget.signal.reason).catch(() => {});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
258
281
|
return { status: response.status, kind: 'stream', headers, bodyStream };
|
|
259
282
|
} catch (error) {
|
|
283
|
+
options.signal?.throwIfAborted();
|
|
260
284
|
if ((controller && controller.signal.aborted) || error && error.name === 'AbortError') {
|
|
261
285
|
throw new hostRuntime.CanonicalRuntimeError('FetchTimeoutError', 'PULSE_FETCH_TIMEOUT', 'Live Node fetch exceeded the configured timeout.', { effectId: normalized.id, url: normalized.parts.url, timeoutMs });
|
|
262
286
|
}
|
|
@@ -264,6 +288,7 @@ async function executeLiveFetch(normalized, fetchImplementation, options = {}) {
|
|
|
264
288
|
throw new hostRuntime.CanonicalRuntimeError('FetchNetworkError', 'PULSE_FETCH_NETWORK', `Live Node fetch failed for ${normalized.init.method} ${normalized.parts.url}.`, { effectId: normalized.id, url: normalized.parts.url, message: error && error.message ? error.message : String(error) });
|
|
265
289
|
} finally {
|
|
266
290
|
if (timeout) clearTimeout(timeout);
|
|
291
|
+
options.signal?.removeEventListener('abort', cancel);
|
|
267
292
|
}
|
|
268
293
|
}
|
|
269
294
|
|
|
@@ -501,6 +526,8 @@ function createNodeProviderAdapter(baseOptions = {}) {
|
|
|
501
526
|
},
|
|
502
527
|
dispatchFetch,
|
|
503
528
|
async dispatchEffect(effect, executionOptions = {}) {
|
|
529
|
+
if (effect.kind === 'crypto.digestText') return require('@pulse-compute/crypto/provider').executeTextDigest(effect, executionOptions);
|
|
530
|
+
if (effect.kind === 'time.now') return portableRuntimeHost.readWallTime(baseOptions.wallClock === undefined ? () => Date.now() : baseOptions.wallClock);
|
|
504
531
|
if (['s3.head', 's3.getText', 's3.putText'].includes(effect.kind)) {
|
|
505
532
|
return require('./s3-reader.js').readS3(effect, { ...baseOptions, ...executionOptions,
|
|
506
533
|
fetchImplementation: executionOptions.s3FetchImplementation || baseOptions.s3FetchImplementation
|
package/src/runtime/s3-reader.js
CHANGED
|
@@ -54,6 +54,7 @@ async function readS3(effect, options, lookup) {
|
|
|
54
54
|
let timedOut = false;
|
|
55
55
|
const deadline = performance.now() + binding.timeoutMs;
|
|
56
56
|
const check = () => {
|
|
57
|
+
options.requestBudget?.check();
|
|
57
58
|
if (performance.now() >= deadline) { timedOut = true; controller.abort(); }
|
|
58
59
|
if (controller.signal.aborted) throw controller.signal.reason || new Error('S3 deadline expired.');
|
|
59
60
|
};
|
|
@@ -65,7 +66,9 @@ async function readS3(effect, options, lookup) {
|
|
|
65
66
|
if (key === null) return failure('invalid-key');
|
|
66
67
|
let body, contentType;
|
|
67
68
|
if (put) {
|
|
68
|
-
if (typeof payload.text !== 'string'
|
|
69
|
+
if (typeof payload.text !== 'string') return failure('invalid-text');
|
|
70
|
+
if (payload.text.length > binding.maxTextBytes) return failure('too-large');
|
|
71
|
+
if (!payload.text.isWellFormed()) return failure('invalid-text');
|
|
69
72
|
body = protocol.bytes(payload.text);
|
|
70
73
|
if (body.length > binding.maxTextBytes) return failure('too-large');
|
|
71
74
|
try { contentType = protocol.normalizePutOptions({ contentType: payload.contentType }).contentType; }
|
package/src/toolchain.js
CHANGED
|
@@ -36,7 +36,7 @@ function genericProviderConfig(value) {
|
|
|
36
36
|
const input = value && typeof value === 'object' ? value : {};
|
|
37
37
|
const bindings = input.bindings || {};
|
|
38
38
|
if (Object.keys(bindings).some((key) => key !== 's3')) throw new TypeError('Unknown Node binding field.');
|
|
39
|
-
return Object.freeze({ kind: 'node', bindings: Object.freeze({ s3: require('./config/s3.js').normalizeNodeS3(bindings.s3) }), local: Object.freeze({}) });
|
|
39
|
+
return Object.freeze({ kind: 'node', maxDurationMs: require('@pulse-compute/runtime/host').normalizeRequestDuration(input.maxDurationMs), bindings: Object.freeze({ s3: require('./config/s3.js').normalizeNodeS3(bindings.s3) }), local: Object.freeze({}) });
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
function nodeRealization(nativeArtifact) {
|
|
@@ -144,18 +144,19 @@ function createDriver() {
|
|
|
144
144
|
normalizeConfig: genericProviderConfig,
|
|
145
145
|
configReference: Object.freeze({
|
|
146
146
|
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–
|
|
147
|
+
fields: [{ 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',
|
|
148
|
+
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
149
|
security: 'Only named credential references are configuration. Runtime keys cannot override authority.' }]
|
|
150
150
|
}),
|
|
151
151
|
defaultLocalNetworkFetch: true,
|
|
152
|
-
projectConfigDocument(config) { return Object.keys(config.bindings.s3).length ? config : undefined; },
|
|
152
|
+
projectConfigDocument(config) { return (Object.keys(config.bindings.s3).length || config.maxDurationMs !== undefined) ? config : undefined; },
|
|
153
153
|
initTemplate() {
|
|
154
154
|
return Object.freeze({ profileFragment: '', dependencies: Object.freeze({}) });
|
|
155
155
|
},
|
|
156
156
|
executionOptions(config, values) {
|
|
157
157
|
return Object.freeze({
|
|
158
158
|
...values,
|
|
159
|
+
maxDurationMs: config.maxDurationMs,
|
|
159
160
|
s3: config.bindings.s3,
|
|
160
161
|
s3FetchImplementation: values.s3FetchImplementation || require('./javascript/fetch-adapter.js').createNodeJavascriptFixtureFetch(
|
|
161
162
|
values.fetches || {}, values.fetchImplementation || (values.liveFetch === true ? globalThis.fetch : undefined),
|