@pulse-compute/provider-node 1.0.0-beta.4 → 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 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.4/packages/implementation-packages/)
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.4` package policy.
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.4/concepts/contracts-and-providers/) and [Implementation packages](https://pulsecompute.io/v1.0.0-beta.4/packages/implementation-packages/).
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.4",
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.4",
46
- "@pulse-compute/jwt": "1.0.0-beta.4",
47
- "@pulse-compute/runtime": "1.0.0-beta.4",
48
- "@pulse-compute/s3": "1.0.0-beta.4",
49
- "@pulse-compute/wasm-build-support": "1.0.0-beta.4",
50
- "@pulse-compute/wasm-contracts": "1.0.0-beta.4",
51
- "@pulse-compute/wasm-host-runtime": "1.0.0-beta.4"
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.4/packages/implementation-packages/",
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
  }
@@ -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
- response.end(response.statusCode === 413 ? 'Payload Too Large' : (response.statusCode === 400 ? 'Bad Request' : 'Internal Server Error'));
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
- const chunks = [];
74
- let bytes = 0;
75
- try {
76
- for await (const chunk of request) {
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
- } catch (error) {
89
- if (error instanceof PulseNodeJavascriptRequestError) throw error;
90
- throw new PulseNodeJavascriptRequestError(
91
- 'PULSE_NODE_REQUEST_READ_FAILED',
92
- 'Pulse could not read the Node request body.',
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
- nodeResponse.statusCode = response.status;
148
- if (response.statusText && typeof nodeResponse.statusMessage === 'string') nodeResponse.statusMessage = response.statusText;
149
- for (const group of groupHeaderPairs(runtimeHost.responseHeaderPairs(response))) {
150
- nodeResponse.setHeader(group.name, group.values.length === 1 ? group.values[0] : [...group.values]);
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
- const method = String(options.requestMethod || 'GET').toUpperCase();
154
- if (method === 'HEAD' || !statusAllowsBody(response.status) || response.body == null) {
155
- nodeResponse.end();
156
- return response;
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
- if (typeof Readable.fromWeb === 'function') {
160
- await pipeline(Readable.fromWeb(response.body), nodeResponse);
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 adapted = await nodeRequestToWebRequest(request, options);
193
- const requestContext = Object.freeze({
194
- request,
195
- response,
196
- webRequest: adapted.request,
197
- headerPairs: adapted.headerPairs
198
- });
199
- const requestedCapabilities = await resolveRequestOption(options.capabilities, requestContext);
200
- const effectAdapter = await resolveRequestOption(options.effectAdapter, requestContext);
201
- const schemaCodecs = await resolveRequestOption(options.schemaCodecs, requestContext);
202
- const config = hasDynamicBindings ? await resolveRequestOption(options.config, requestContext) : options.config;
203
- const secrets = hasDynamicBindings ? await resolveRequestOption(options.secrets, requestContext) : options.secrets;
204
- const kv = hasDynamicBindings ? await resolveRequestOption(options.kv, requestContext) : options.kv;
205
- const bindings = staticBindings || createNodeJavascriptBindingCapabilities({
206
- config, secrets, kv,
207
- kvReference: options.kvReference,
208
- maxBindingNameBytes: options.maxBindingNameBytes,
209
- maxBindingValueBytes: options.maxBindingValueBytes,
210
- maxKvNamespaceBytes: options.maxKvNamespaceBytes,
211
- maxKvKeyBytes: options.maxKvKeyBytes,
212
- maxKvValueBytes: options.maxKvValueBytes,
213
- maxKvValueDepth: options.maxKvValueDepth,
214
- maxKvValueEntries: options.maxKvValueEntries
215
- });
216
- const capabilities = effectAdapter === undefined
217
- ? withNodeBindingCapabilities(requestedCapabilities, { bindings })
218
- : requestedCapabilities;
219
- const started = process.hrtime.bigint();
220
- const activeApplication = options.getApplication
221
- ? runtimeHost.normalizeApplication(options.getApplication())
222
- : normalizedApplication;
223
- const webResponse = await executeNodeJavascriptApplication(activeApplication, adapted.request, {
224
- capabilities,
225
- effectAdapter,
226
- config,
227
- secrets,
228
- kv,
229
- application: options.application,
230
- requestHeaders: adapted.headerPairs,
231
- signal: options.signal || adapted.request.signal,
232
- fetchImplementation: options.fetchImplementation,
233
- s3: options.s3 || options.bindings && options.bindings.s3,
234
- s3FetchImplementation: options.s3FetchImplementation,
235
- assetsLookup: options.assetsLookup,
236
- gripBroadcast: options.gripBroadcast,
237
- jwtCaptureWallClock: options.jwtCaptureWallClock,
238
- assets: options.assets,
239
- grip: options.grip,
240
- maxEffects: options.maxEffects,
241
- kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
242
- maxRequestBodyBytes: options.maxRequestBodyBytes ?? options.maxBodyBytes,
243
- maxFetchBodyBytes: options.maxFetchBodyBytes,
244
- maxStructuredBodyBytes: options.maxStructuredBodyBytes,
245
- maxBindingNameBytes: options.maxBindingNameBytes,
246
- maxBindingValueBytes: options.maxBindingValueBytes,
247
- maxKvNamespaceBytes: options.maxKvNamespaceBytes,
248
- maxKvKeyBytes: options.maxKvKeyBytes,
249
- maxKvValueBytes: options.maxKvValueBytes,
250
- maxKvValueDepth: options.maxKvValueDepth,
251
- maxKvValueEntries: options.maxKvValueEntries,
252
- schemaCodecs,
253
- strict: options.strict === true,
254
- provider: options.provider || 'node',
255
- redactionValues: options.redactionValues,
256
- onJsonTrace: options.onJsonTrace,
257
- onEffectObservation: options.onEffectObservation,
258
- onEffectSummary: options.onEffectSummary
259
- });
260
- await writeWebResponseToNode(webResponse, response, { requestMethod: adapted.method });
261
- if (typeof options.onRequest === 'function') {
262
- options.onRequest(Object.freeze({
263
- method: adapted.method,
264
- url: adapted.request.url,
265
- path: new URL(adapted.request.url).pathname,
266
- status: webResponse.status,
267
- durationMs: Number(process.hrtime.bigint() - started) / 1e6
268
- }));
269
- }
270
- return webResponse;
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.assets'
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 = withNodeBindingCapabilities(options.capabilities, bindingOptions);
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,
@@ -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 = Number.isFinite(timeoutMs) && timeoutMs > 0 ? new AbortController() : undefined;
238
- const timeout = controller ? setTimeout(() => controller.abort(new Error(`Fetch timed out after ${timeoutMs}ms.`)), timeoutMs) : undefined;
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
@@ -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' || !payload.text.isWellFormed()) return failure('invalid-text');
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–32768, default 32768) and timeoutMs (1–30000, default 10000).',
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),