@pulse-compute/runtime 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.
@@ -2,6 +2,8 @@
2
2
 
3
3
  const { createStructuredBodyReader, normalizeBodyLimit } = require('./body.js');
4
4
  const { normalizeFetchRequest } = require('./fetch.js');
5
+ const { isIncomingBody } = require('./incoming-body.js');
6
+ const { PulseRuntimeContractError } = require('./errors.js');
5
7
  const { decodeSchemaText, encodeSchemaValue, requireExplicitSchemaId, strictSchemaPolicy } = require('./schema.js');
6
8
  const {
7
9
  cloneKvValue,
@@ -20,6 +22,10 @@ const {
20
22
  projectFetchResponse
21
23
  } = require('./response.js');
22
24
 
25
+ // Router entries create new contexts but share one effect execution. Keep fetch
26
+ // numbering with that execution, without retaining completed requests.
27
+ const fetchSequences = new WeakMap();
28
+
23
29
  function headersToPairs(headers) {
24
30
  const pairs = [];
25
31
  for (const [name, value] of headers.entries()) pairs.push(Object.freeze([name, value]));
@@ -35,6 +41,7 @@ function normalizeRequestHeaderPairs(headers, fallback) {
35
41
  }
36
42
 
37
43
  function createRequestView(request, requestHeaders, effectExecution, options = {}) {
44
+ options.incomingBody?.bindInvalidation(error => effectExecution.invalidateAdmission(error));
38
45
  const url = new URL(request.url);
39
46
  const headers = normalizeRequestHeaderPairs(requestHeaders, request.headers);
40
47
  const body = createStructuredBodyReader(request, {
@@ -52,16 +59,28 @@ function createRequestView(request, requestHeaders, effectExecution, options = {
52
59
  url: request.url,
53
60
  path: url.pathname || '/',
54
61
  headers,
62
+ body(...args) {
63
+ if (args.length) throw new TypeError('ctx.req.body takes no arguments.');
64
+ if (!options.incomingBody) throw new PulseRuntimeContractError(
65
+ 'PULSE_REQUEST_FORWARDING_UNAVAILABLE', 'Incoming body forwarding requires the configured Node JavaScript provider.');
66
+ return options.incomingBody.marker();
67
+ },
68
+ readTextChunk(...args) {
69
+ if (args.length) throw new TypeError('ctx.req.readTextChunk takes no arguments.');
70
+ return effectExecution.dispatch({ kind: 'output.readTextChunk', providerKind: 'output', operation: 'readTextChunk', capability: 'request.body.transform', parallelEligible: false });
71
+ },
55
72
  header(name) {
56
73
  const lower = String(name).toLowerCase();
57
74
  const match = headers.find(([header]) => header.toLowerCase() === lower);
58
75
  return match ? match[1] : undefined;
59
76
  },
60
77
  text() {
78
+ options.incomingBody?.structured();
61
79
  if (!textEffect) textEffect = effectExecution.local('request.body.text', () => body.text());
62
80
  return textEffect;
63
81
  },
64
82
  json(schemaId) {
83
+ options.incomingBody?.structured();
65
84
  if (schemaId === undefined && !strictSchemaPolicy(options)) {
66
85
  if (!jsonEffect) jsonEffect = effectExecution.local('request.body.json', () => body.json());
67
86
  return jsonEffect;
@@ -145,7 +164,6 @@ function createContext(frame) {
145
164
  ...(frame.executionOptions || {}),
146
165
  signal: frame.signal
147
166
  });
148
- let fetchSequence = 0;
149
167
  const log = createPulseLogger({
150
168
  reporting: options.reporting,
151
169
  redact(message) { return effects.redactValue(message); },
@@ -161,17 +179,32 @@ function createContext(frame) {
161
179
  const id = requireExplicitSchemaId(schemaId, options, 'application-value');
162
180
  return encodeSchemaValue(id, value, options, { source: 'application-value' }).text;
163
181
  },
182
+ decodeJson(text, schemaId) {
183
+ const id = requireExplicitSchemaId(schemaId, options, 'application-text');
184
+ return decodeSchemaText(id, text, options, { source: 'application-text' });
185
+ },
164
186
  ...(eventContext ? {} : { req }),
165
187
  state,
166
188
  log,
167
189
  fetch(url, init) {
168
- fetchSequence += 1;
190
+ const fetchSequence = (fetchSequences.get(effects) || 0) + 1;
191
+ fetchSequences.set(effects, fetchSequence);
169
192
  const effectId = `fetch-${fetchSequence}`;
170
- const fetchRequest = normalizeFetchRequest(url, init, {
171
- ...options,
172
- operationId: `fetch-request:${effectId}`,
173
- effectId
174
- });
193
+ let fetchRequest;
194
+ try {
195
+ fetchRequest = normalizeFetchRequest(url, init, {
196
+ ...options,
197
+ operationId: `fetch-request:${effectId}`,
198
+ effectId
199
+ });
200
+ if (fetchRequest.init.bodyMode === 'incoming-request-v1') {
201
+ if (!options.incomingBody) throw new PulseRuntimeContractError('PULSE_REQUEST_BODY_OWNERSHIP', 'Incoming body belongs to another execution.');
202
+ options.incomingBody.claim(fetchRequest.init.body, fetchRequest.init);
203
+ }
204
+ } catch (error) {
205
+ if (isIncomingBody(init?.body)) effects.invalidateAdmission(error);
206
+ throw error;
207
+ }
175
208
  const fetchEffect = effects.dispatch({
176
209
  id: effectId,
177
210
  kind: 'fetch',
@@ -183,6 +216,12 @@ function createContext(frame) {
183
216
  });
184
217
  return createFetchOperation(effects, fetchEffect, fetchRequest, options, effectId);
185
218
  },
219
+ time: Object.freeze({
220
+ now(...args) {
221
+ if (args.length) throw new TypeError('Pulse time.now takes no arguments.');
222
+ return effects.dispatch({ kind: 'time.now', providerKind: 'time', operation: 'now', capability: 'time.wall-clock' });
223
+ }
224
+ }),
186
225
  parallel(record) { return effects.parallel(record); },
187
226
  config: Object.freeze({
188
227
  get(name) {
@@ -256,6 +295,26 @@ function createContext(frame) {
256
295
  if (eventContext) {
257
296
  ctx.event = frame.event;
258
297
  } else {
298
+ const output = () => {
299
+ if (!options.outputExecution) throw new PulseRuntimeContractError('PULSE_OUTPUT_UNAVAILABLE', 'Generated output requires configured Node HTTP execution.');
300
+ return options.outputExecution;
301
+ };
302
+ ctx.output = Object.freeze({
303
+ start(...args) {
304
+ if (args.length > 1) throw new TypeError('output.start accepts one options object.');
305
+ output().assertEffect();
306
+ return effects.dispatch({ kind: 'output.start', providerKind: 'output', operation: 'start', capability: 'response.output', parallelEligible: false, argument0: args[0] });
307
+ },
308
+ write(...args) {
309
+ if (args.length !== 1) throw new TypeError('output.write requires one text chunk.');
310
+ output().assertEffect();
311
+ return effects.dispatch({ kind: 'output.write', providerKind: 'output', operation: 'write', capability: 'response.output', parallelEligible: false, argument0: args[0] });
312
+ },
313
+ close(...args) {
314
+ if (args.length) throw new TypeError('output.close accepts no arguments.');
315
+ return output().close(init => createTextResult('', init));
316
+ }
317
+ });
259
318
  ctx.json = (value, descriptor) => createJsonResult(value, descriptor, options);
260
319
  ctx.text = (value, responseOptions) => createTextResult(value, responseOptions);
261
320
  ctx.response = (input) => createResponseResult(input);
@@ -9,11 +9,13 @@ const {
9
9
  normalizeKvNamespace,
10
10
  normalizeKvPutResult
11
11
  } = require('./bindings.js');
12
+ const { normalizeTimeResult, readWallTime } = require('./time.js');
12
13
  const conditionalKv = require('./conditional-kv.js');
13
14
  const { fetchRequestInitForHost } = require('./fetch.js');
14
15
  const { createRedactionState } = require('./redaction.js');
15
16
  const { validateSchemaValue } = require('./schema.js');
16
17
  const { EVENT_EMIT_CODES } = require('./event-emission.js');
18
+ const { cancelResponseBody, retainFetchResponseForBudget, releaseFetchResponseCancellation } = require('./response.js');
17
19
 
18
20
  const JAVASCRIPT_EFFECT_PROTOCOL_VERSION = 'pulse.javascript-effect.v1';
19
21
  const JAVASCRIPT_EFFECT_ADAPTER_VERSION = 'pulse.javascript-effect-adapter.v1';
@@ -244,6 +246,7 @@ function createCapabilityEffectAdapter(capabilities = {}) {
244
246
  if (typeof value.fetch !== 'function') return unavailableCapability(effect);
245
247
  return value.fetch(effect.url, fetchRequestInitForHost(effect.init, execution.signal), execution);
246
248
  }
249
+ if (effect.kind === 'time.now') return typeof value.time === 'function' ? value.time(execution) : readWallTime();
247
250
  if (effect.kind === 'config.get') {
248
251
  if (typeof value.config !== 'function') return unavailableCapability(effect);
249
252
  return value.config(effect.name, execution);
@@ -305,6 +308,7 @@ function normalizeEffectDescriptor(input, limits) {
305
308
  }
306
309
 
307
310
  function normalizeEffectResult(descriptor, value, limits) {
311
+ if (descriptor.kind === 'time.now') return normalizeTimeResult(value);
308
312
  if (descriptor.kind === 'config.get' || descriptor.kind === 'secret.get') {
309
313
  return normalizeBindingValue(descriptor.kind, descriptor.name, value, limits);
310
314
  }
@@ -490,6 +494,8 @@ function createJavascriptEffectExecution(options = {}) {
490
494
  event: options.event,
491
495
  application: options.application,
492
496
  signal: lifecycleController.signal,
497
+ deadlineMonotonicMs: options.deadlineMonotonicMs,
498
+ requestBudget: options.requestBudget,
493
499
  registerRedactionValue(value) {
494
500
  if (typeof value === 'string' && value.length > 0) redaction.add(value);
495
501
  },
@@ -505,6 +511,7 @@ function createJavascriptEffectExecution(options = {}) {
505
511
  const counters = new Map();
506
512
  const dispatchedIds = new Set();
507
513
  const pendingEffects = new Map();
514
+ const ownedResponses = new Set();
508
515
  const claimedParallelRootIds = new Set();
509
516
  const observations = [];
510
517
  const resolutionOrder = [];
@@ -541,6 +548,7 @@ function createJavascriptEffectExecution(options = {}) {
541
548
  'Pulse effect execution is already closed.'
542
549
  );
543
550
  }
551
+ options.requestBudget?.check();
544
552
  }
545
553
 
546
554
  function trackEffect(effect, data) {
@@ -565,6 +573,7 @@ function createJavascriptEffectExecution(options = {}) {
565
573
  limitMessage
566
574
  );
567
575
  }
576
+ options.outputExecution?.assertEffect();
568
577
  const normalizedInput = normalizeEffectDescriptor(input, limits);
569
578
  const kind = String(normalizedInput.kind || 'effect');
570
579
  const descriptor = Object.freeze({
@@ -597,20 +606,36 @@ function createJavascriptEffectExecution(options = {}) {
597
606
  const operationSignal = createEffectSignal(lifecycleController.signal, descriptor);
598
607
  const operationExecution = Object.freeze({ ...externalExecution, signal: operationSignal.signal });
599
608
  const raw = raceWithSignal(Promise.resolve().then(() => {
609
+ options.requestBudget?.check();
600
610
  if (operationSignal.signal.aborted) throw abortedEffectError(operationSignal.signal.reason);
611
+ options.outputExecution?.assertEffect();
612
+ if (kind.startsWith('output.')) {
613
+ if (!options.outputExecution) throw new PulseRuntimeContractError('PULSE_OUTPUT_UNAVAILABLE', 'Generated output requires a Node HTTP writer.');
614
+ return options.outputExecution.dispatch(descriptor);
615
+ }
601
616
  if (conditionalKv.isConditionalKv(kind)) return conditionalKv.executeConditionalKv(descriptor,
602
617
  adapter.prepareConditionalKv || ((_admitted, kvExecution) => () => adapter.dispatch(descriptor, kvExecution)),
603
618
  { ...operationExecution, onKvObservation: observe }, limits);
604
- return adapter.dispatch(descriptor, operationExecution);
619
+ return Promise.resolve(adapter.dispatch(descriptor, operationExecution)).then(value => {
620
+ if (value instanceof Response && value.body) {
621
+ if (closed || operationSignal.signal.aborted) cancelResponseBody(value, operationSignal.signal.reason);
622
+ else {
623
+ ownedResponses.add(value);
624
+ retainFetchResponseForBudget(value, options.requestBudget, () => ownedResponses.delete(value));
625
+ }
626
+ }
627
+ return value;
628
+ });
605
629
  }), operationSignal.signal).then(
606
630
  (value) => {
631
+ options.requestBudget?.check();
607
632
  const normalized = normalizeEffectResult(descriptor, value, limits);
608
633
  if (descriptor.kind === 'secret.get' && typeof normalized === 'string') redaction.add(normalized);
609
634
  return normalized;
610
635
  },
611
636
  (error) => { throw redaction.redactError(error); }
612
637
  ).finally(operationSignal.dispose);
613
- const projected = (typeof projector === 'function' ? raw.then(projector) : raw).catch((error) => {
638
+ const projected = (typeof projector === 'function' ? raw.then(value => { options.requestBudget?.check(); return projector(value); }).then(value => { options.requestBudget?.check(); return value; }) : raw).catch((error) => {
614
639
  throw redaction.redactError(error);
615
640
  });
616
641
  const effectData = {
@@ -672,9 +697,10 @@ function createJavascriptEffectExecution(options = {}) {
672
697
  const publicDescriptor = publicEffectDescriptor(descriptor, redaction);
673
698
  observe({ type: 'effect-dispatched', effect: publicDescriptor });
674
699
  const promise = Promise.resolve().then(() => {
700
+ options.requestBudget?.check();
675
701
  if (lifecycleController.signal.aborted) throw abortedEffectError(lifecycleController.signal.reason);
676
702
  return producer(externalExecution);
677
- }).catch((error) => { throw redaction.redactError(error); });
703
+ }).then(value => { options.requestBudget?.check(); return value; }).catch((error) => { throw redaction.redactError(error); });
678
704
  const effectData = {
679
705
  execution,
680
706
  id,
@@ -711,7 +737,7 @@ function createJavascriptEffectExecution(options = {}) {
711
737
  : 'Pulse effect projections require a request-owned Pulse effect.'
712
738
  );
713
739
  }
714
- const projected = raceWithSignal(parent.then(projector), lifecycleController.signal).catch((error) => {
740
+ const projected = raceWithSignal(parent.then(value => { options.requestBudget?.check(); return projector(value); }).then(value => { options.requestBudget?.check(); return value; }), lifecycleController.signal).catch((error) => {
715
741
  throw redaction.redactError(error);
716
742
  });
717
743
  const effectData = {
@@ -723,7 +749,12 @@ function createJavascriptEffectExecution(options = {}) {
723
749
 
724
750
  parallel(record) {
725
751
  assertOpen();
726
- const entries = validateParallelRecord(record, execution, claimedParallelRootIds);
752
+ let entries;
753
+ try { entries = validateParallelRecord(record, execution, claimedParallelRootIds); }
754
+ catch (error) {
755
+ if (record && Object.values(Object.getOwnPropertyDescriptors(record)).some(field => dataForEffect(field.value)?.kind?.startsWith('output.'))) execution.invalidateAdmission(error);
756
+ throw error;
757
+ }
727
758
  for (const entry of entries) claimedParallelRootIds.add(entry.data.rootId);
728
759
  parallelCount += 1;
729
760
  const id = `parallel-${parallelCount}`;
@@ -790,6 +821,11 @@ function createJavascriptEffectExecution(options = {}) {
790
821
  );
791
822
  return parallelEffect;
792
823
  },
824
+ invalidateAdmission(error) {
825
+ // A synchronous body-claim conflict must fence every already queued
826
+ // group member before its provider dispatch microtask can run.
827
+ if (!lifecycleController.signal.aborted) lifecycleController.abort(error);
828
+ },
793
829
 
794
830
  async assertIdle() {
795
831
  if (pendingEffects.size === 0) return;
@@ -840,7 +876,7 @@ function createJavascriptEffectExecution(options = {}) {
840
876
  });
841
877
  },
842
878
 
843
- async close() {
879
+ async close(transferredResponse) {
844
880
  if (closed) return;
845
881
  closed = true;
846
882
  if (pendingEffects.size > 0 && !lifecycleController.signal.aborted) {
@@ -853,9 +889,21 @@ function createJavascriptEffectExecution(options = {}) {
853
889
  }
854
890
  if (pendingEffects.size > 0) await Promise.allSettled(Array.from(pendingEffects.keys()));
855
891
  if (removeSourceAbortListener) removeSourceAbortListener();
856
- if (adapter.dispose) {
857
- try { await adapter.dispose(externalExecution); }
858
- catch (error) { throw redaction.redactError(error); }
892
+ try {
893
+ if (adapter.dispose) await adapter.dispose(externalExecution);
894
+ } catch (error) {
895
+ transferredResponse = undefined;
896
+ throw redaction.redactError(error);
897
+ } finally {
898
+ // Only the final response escapes this execution. Intermediate router
899
+ // responses and successful siblings of a failed group remain owned.
900
+ for (const response of ownedResponses) {
901
+ if (response.body !== transferredResponse?.body) {
902
+ cancelResponseBody(response, lifecycleController.signal.reason);
903
+ releaseFetchResponseCancellation(response);
904
+ }
905
+ }
906
+ ownedResponses.clear();
859
907
  }
860
908
  }
861
909
  };
@@ -24,4 +24,32 @@ class PulseUnhandledError extends Error {
24
24
  }
25
25
  }
26
26
 
27
- module.exports = Object.freeze({ PulseRuntimeContractError, PulseUnhandledError });
27
+ // Data failures admitted to the Router error lane. Configuration, provider
28
+ // protocol failures and VM traps are deliberately outside this catalog.
29
+ const APPLICATION_ERROR_CODES = new Set([
30
+ 'PULSE_SCHEMA_DECODE', 'PULSE_SCHEMA_ENCODE',
31
+ 'PULSE_SCHEMA_JSON_MALFORMED', 'PULSE_SCHEMA_CONTENT_TYPE',
32
+ 'PULSE_BODY_TOO_LARGE', 'PULSE_REQUEST_BODY_INVALID_UTF8',
33
+ 'PULSE_JWT_TOKEN_REQUIRED', 'PULSE_JWT_BEARER_INVALID',
34
+ 'PULSE_JWT_MALFORMED', 'PULSE_JWT_LIMIT_EXCEEDED',
35
+ 'PULSE_JWT_ALGORITHM_NOT_ALLOWED', 'PULSE_JWT_KEY_INVALID',
36
+ 'PULSE_JWT_SIGNATURE_INVALID', 'PULSE_JWT_CLAIMS_INVALID',
37
+ 'PULSE_JWT_CLAIMS_SCHEMA_INVALID', 'PULSE_JWT_CLOCK_INVALID'
38
+ ]);
39
+
40
+ function isApplicationError(error) {
41
+ if (!(error instanceof Error)) return false;
42
+ const code = Object.getOwnPropertyDescriptor(error, 'code')?.value;
43
+ if (!APPLICATION_ERROR_CODES.has(code)) return false;
44
+ const seen = new Set();
45
+ for (let cause = error; cause instanceof Error; cause = Object.getOwnPropertyDescriptor(cause, 'cause')?.value) {
46
+ if (seen.has(cause)) return false;
47
+ seen.add(cause);
48
+ if (cause instanceof WebAssembly.RuntimeError
49
+ || Object.getOwnPropertyDescriptor(cause, 'code')?.value === 'PULSE_CANONICAL_NATIVE_AS_ABORT'
50
+ || Object.getOwnPropertyDescriptor(cause, 'name')?.value === 'AbortError') return false;
51
+ }
52
+ return true;
53
+ }
54
+
55
+ module.exports = Object.freeze({ PulseRuntimeContractError, PulseUnhandledError, isApplicationError });
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { PulseRuntimeContractError } = require('./errors.js');
4
4
  const { encodeSchemaValue, requireExplicitSchemaId } = require('./schema.js');
5
+ const { isIncomingBody } = require('./incoming-body.js');
5
6
 
6
7
  const SUPPORTED_FETCH_METHODS = Object.freeze(new Set(['GET', 'HEAD', 'POST']));
7
8
  const SUPPORTED_FETCH_INIT_FIELDS = Object.freeze(new Set(['method', 'headers', 'body', 'json', 'schema', 'timeoutMs']));
@@ -153,11 +154,11 @@ function normalizeFetchInit(input, options = {}) {
153
154
  let body;
154
155
  let bodyMode = 'none';
155
156
  if (hasBody) {
156
- if (typeof value.body !== 'string') {
157
+ if (typeof value.body !== 'string' && !isIncomingBody(value.body)) {
157
158
  throw fetchContractError('PULSE_FETCH_BODY_INVALID', 'Pulse fetch body must be a string.', { type: typeof value.body });
158
159
  }
159
160
  body = value.body;
160
- bodyMode = 'text';
161
+ bodyMode = isIncomingBody(value.body) ? 'incoming-request-v1' : 'text';
161
162
  } else if (hasJson) {
162
163
  if (!hasHeader(headers, 'content-type')) {
163
164
  headers.push(Object.freeze(['content-type', 'application/json; charset=utf-8']));
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ const { PulseRuntimeContractError } = require('./errors.js');
4
+ const owners = new WeakMap();
5
+ function conflict() {
6
+ return new PulseRuntimeContractError('PULSE_REQUEST_BODY_OWNERSHIP', 'The incoming body has already been claimed by this request.');
7
+ }
8
+
9
+ // Host integration only. Markers carry no stream, bytes or serializable identity.
10
+ function createIncomingBodyOwnership(transport) {
11
+ let state = 'available', invalidation, failure, disposed = false;
12
+ const marker = Object.freeze(Object.create(null));
13
+ const owner = Object.freeze({
14
+ marker() { return marker; },
15
+ bindInvalidation(callback) { invalidation = callback; },
16
+ fail(error) {
17
+ failure ||= error;
18
+ state = 'failed';
19
+ invalidation?.(error);
20
+ return error;
21
+ },
22
+ structured() {
23
+ if (transport.readTextChunk) throw owner.fail(conflict());
24
+ if (state !== 'available' && state !== 'structured') throw owner.fail(conflict());
25
+ state = 'structured';
26
+ },
27
+ claim(value, init) {
28
+ if (owners.get(value) !== owner || state !== 'available') throw owner.fail(conflict());
29
+ transport.validate(init);
30
+ state = 'reserved';
31
+ },
32
+ async readTextChunk(budget) {
33
+ if (!transport.readTextChunk || !['available', 'reading'].includes(state)) throw owner.fail(conflict());
34
+ state = 'reading';
35
+ try { return await transport.readTextChunk(budget); } catch (error) { throw owner.fail(error); }
36
+ },
37
+ async forward(url, init, execution) {
38
+ if (state !== 'reserved') throw failure || conflict();
39
+ state = 'forwarding';
40
+ try {
41
+ const response = await transport.forward(url, init, execution);
42
+ state = 'closed';
43
+ return response;
44
+ } catch (error) { throw owner.fail(error); }
45
+ },
46
+ async close() {
47
+ if (disposed) return;
48
+ disposed = true;
49
+ if (state !== 'closed' && state !== 'failed') state = 'cancelled';
50
+ // Provider cleanup is best effort, must observe rejection, and never
51
+ // delay an execution or grant application authority.
52
+ try { Promise.resolve(transport.cancel(failure)).catch(() => {}); } catch (_) {}
53
+ },
54
+ get failure() { return failure; },
55
+ get responseSignal() { return transport.responseSignal; }
56
+ });
57
+ owners.set(marker, owner);
58
+ return owner;
59
+ }
60
+
61
+ function isIncomingBody(value) { return value !== null && typeof value === 'object' && owners.has(value); }
62
+ function forwardIncomingBody(value, url, init, execution) {
63
+ const owner = owners.get(value);
64
+ if (!owner) throw conflict();
65
+ return owner.forward(url, init, execution);
66
+ }
67
+
68
+ module.exports = { createIncomingBodyOwnership, isIncomingBody, forwardIncomingBody };
@@ -10,14 +10,23 @@ const {
10
10
  const PACKAGE_RUNTIME_BRIDGE_VERSION = 'pulse.package-runtime-bridge.v1';
11
11
  const PACKAGE_SCHEMA_CODEC_BRIDGE_VERSION = 'pulse.first-party-embedded-schema-codec-bridge.v1';
12
12
  const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024;
13
+ // Fixed first-party text operations alone admit 2 MiB plus worst-case JSON
14
+ // escaping and 64 KiB metadata. Other package operations retain their bounds.
15
+ const TEXT_MAX_PAYLOAD_BYTES = 6 * 2097152 + 65536;
13
16
  const DEFAULT_MAX_PAYLOAD_DEPTH = 32;
14
17
  const DEFAULT_MAX_PAYLOAD_ENTRIES = 4096;
15
18
 
16
19
  const PACKAGE_CONTEXTS = new WeakMap();
17
20
 
18
21
  const TRUSTED_PACKAGE_EFFECT_CATALOG = Object.freeze({
22
+ '@pulse-compute/crypto': Object.freeze({
23
+ contractId: 'pulse.crypto', providerKind: 'crypto', operations: Object.freeze({
24
+ digestText: Object.freeze({ kind: 'crypto.digestText', capability: 'crypto.digestText', result: 'text-digest-result' })
25
+ })
26
+ }),
19
27
  '@pulse-compute/s3': Object.freeze({
20
28
  contractId: 'pulse.s3', providerKind: 's3', operations: Object.freeze({
29
+ getBody: Object.freeze({ kind: 's3.getBody', capability: 's3.getBody', result: 'opaque-response' }),
21
30
  head: Object.freeze({ kind: 's3.head', capability: 's3.head', result: 's3-head-result' }),
22
31
  getText: Object.freeze({ kind: 's3.getText', capability: 's3.getText', result: 's3-get-text-result' }),
23
32
  putText: Object.freeze({ kind: 's3.putText', capability: 's3.putText', result: 's3-put-text-result' })
@@ -49,6 +58,7 @@ const TRUSTED_PACKAGE_EFFECT_CATALOG = Object.freeze({
49
58
  contractId: 'pulse.jwt',
50
59
  providerKind: 'jwt',
51
60
  operations: Object.freeze({
61
+ sign: Object.freeze({ kind: 'jwt.sign', capability: 'jwt.sign', result: 'string' }),
52
62
  verify: Object.freeze({
53
63
  kind: 'jwt.verify',
54
64
  capability: 'jwt.verify',
@@ -586,7 +596,12 @@ function createPackageRuntime(input) {
586
596
  operation,
587
597
  capability: declared.capability,
588
598
  result: declared.result,
589
- payload: clonePackageEffectPayload(payload)
599
+ payload: clonePackageEffectPayload(payload, {
600
+ // The sign payload adds one envelope level around its 32-level claims.
601
+ maxDepth: declared.kind === 'jwt.sign' ? DEFAULT_MAX_PAYLOAD_DEPTH + 1 : DEFAULT_MAX_PAYLOAD_DEPTH,
602
+ maxBytes: declared.kind === 'crypto.digestText' || declared.kind === 's3.putText'
603
+ ? TEXT_MAX_PAYLOAD_BYTES : DEFAULT_MAX_PAYLOAD_BYTES
604
+ })
590
605
  }, projector);
591
606
  }
592
607
  });
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { PulseRuntimeContractError, PulseUnhandledError } = require('./errors.js');
3
+ const { PulseRuntimeContractError, PulseUnhandledError, isApplicationError } = require('./errors.js');
4
4
 
5
5
  const REDACTED_VALUE = '<redacted>';
6
6
  const CIRCULAR_VALUE = '<circular>';
@@ -121,9 +121,12 @@ function createRedactionState(initialValues = []) {
121
121
  : redactValue(descriptor.value, key, seen, depth + 1);
122
122
  Object.defineProperty(output, outputKey, {
123
123
  enumerable: true,
124
- configurable: false,
124
+ // Different private keys can redact to the same spelling. Their value
125
+ // association is lost, so collapse the collision to a redacted marker.
126
+ // Properties remain replaceable only until the completed record freezes.
127
+ configurable: true,
125
128
  writable: false,
126
- value: outputValue
129
+ value: Object.prototype.hasOwnProperty.call(output, outputKey) ? REDACTED_VALUE : outputValue
127
130
  });
128
131
  }
129
132
  return Object.freeze(output);
@@ -142,6 +145,10 @@ function createRedactionState(initialValues = []) {
142
145
  const rawMessage = ownDataValue(error, 'message');
143
146
  const rawName = ownDataValue(error, 'name');
144
147
  const rawCode = ownDataValue(error, 'code');
148
+ // Admitted discriminators are fixed protocol vocabulary, not private text.
149
+ // Short KV keys/array indexes must not change Router recovery decisions.
150
+ const stableCode = isApplicationError(error) ? rawCode
151
+ : error instanceof PulseUnhandledError ? 'PULSE_RUNTIME_UNHANDLED_ERROR' : undefined;
145
152
  const message = redactString(typeof rawMessage === 'string' ? rawMessage : 'Pulse runtime failure.');
146
153
  const causeDescriptor = Object.getOwnPropertyDescriptor(error, 'cause');
147
154
  const cause = causeDescriptor && Object.prototype.hasOwnProperty.call(causeDescriptor, 'value')
@@ -167,6 +174,10 @@ function createRedactionState(initialValues = []) {
167
174
  }
168
175
 
169
176
  const reserved = new Set(['name', 'message', 'stack', 'cause', 'detail']);
177
+ if (stableCode !== undefined) {
178
+ reserved.add('code');
179
+ Object.defineProperty(safe, 'code', { enumerable: true, configurable: true, writable: false, value: stableCode });
180
+ }
170
181
  for (const key of Reflect.ownKeys(error).slice(0, MAX_REDACTION_ENTRIES)) {
171
182
  if (typeof key !== 'string' || reserved.has(key)) continue;
172
183
  const descriptor = Object.getOwnPropertyDescriptor(error, key);
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ const { PulseRuntimeContractError } = require('./errors.js');
4
+ const budgets = new WeakSet();
5
+
6
+ function normalizeRequestDuration(value) {
7
+ if (value === undefined) return undefined;
8
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > 30000) {
9
+ throw new PulseRuntimeContractError('PULSE_REQUEST_DURATION_INVALID', 'Request maxDurationMs must be an integer from 1 through 30000.');
10
+ }
11
+ return value;
12
+ }
13
+
14
+ // Host-only authority. One budget is passed through adapters; nested calls must
15
+ // never restart it. Wall time and inbound headers are deliberately not inputs.
16
+ function createRequestBudget(options = {}) {
17
+ if (options.requestBudget !== undefined) {
18
+ if (!budgets.has(options.requestBudget)) throw new TypeError('Invalid host request budget.');
19
+ return options.requestBudget;
20
+ }
21
+ const duration = normalizeRequestDuration(options.maxDurationMs);
22
+ const clock = options.requestClock || {
23
+ now: () => performance.now(),
24
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
25
+ clearTimeout: handle => clearTimeout(handle)
26
+ };
27
+ const controller = new AbortController();
28
+ let timer, closed = false, last = -Infinity;
29
+ const read = () => {
30
+ let value;
31
+ try { value = clock.now(); } catch (_) { value = NaN; }
32
+ if (!Number.isFinite(value) || value < last) {
33
+ controller.abort(new PulseRuntimeContractError('PULSE_REQUEST_CLOCK_INVALID', 'Request monotonic clock failed or regressed.'));
34
+ controller.signal.throwIfAborted();
35
+ }
36
+ last = value;
37
+ return value;
38
+ };
39
+ const deadline = duration === undefined ? undefined : read() + duration;
40
+ const expire = () => controller.abort(new PulseRuntimeContractError('PULSE_REQUEST_DEADLINE_EXCEEDED', 'Pulse request exceeded its total execution deadline.'));
41
+ const signals = [...new Set([options.signal, options.requestSignal].filter(Boolean))];
42
+ const listeners = new Set(signals.map(signal => {
43
+ const abort = () => controller.abort(signal.reason);
44
+ if (signal.aborted) abort();
45
+ else signal.addEventListener('abort', abort, { once: true });
46
+ return () => signal.removeEventListener('abort', abort);
47
+ }));
48
+ const budget = Object.freeze({
49
+ signal: controller.signal,
50
+ deadlineMonotonicMs: deadline,
51
+ clock,
52
+ check() {
53
+ if (closed) throw new PulseRuntimeContractError('PULSE_REQUEST_EXECUTION_CLOSED', 'Pulse request execution is closed.');
54
+ if (!controller.signal.aborted && deadline !== undefined && read() >= deadline) expire();
55
+ controller.signal.throwIfAborted();
56
+ },
57
+ onAbort(callback) {
58
+ if (controller.signal.aborted || closed) { callback(); return () => {}; }
59
+ const remove = () => {
60
+ controller.signal.removeEventListener('abort', callback);
61
+ listeners.delete(remove);
62
+ };
63
+ listeners.add(remove);
64
+ controller.signal.addEventListener('abort', callback, { once: true });
65
+ return remove;
66
+ },
67
+ remainingMs() { budget.check(); return deadline === undefined ? Infinity : Math.max(0, deadline - last); },
68
+ race(value) {
69
+ // Always observe the pending work, including an already-expired request.
70
+ const pending = Promise.resolve(value);
71
+ return new Promise((resolve, reject) => {
72
+ const abort = () => { cleanup(); reject(controller.signal.reason); };
73
+ controller.signal.addEventListener('abort', abort, { once: true });
74
+ const cleanup = () => controller.signal.removeEventListener('abort', abort);
75
+ pending.then(result => {
76
+ cleanup();
77
+ try { budget.check(); resolve(result); } catch (error) { reject(error); }
78
+ }, error => { cleanup(); reject(error); });
79
+ try { budget.check(); } catch (error) { cleanup(); reject(error); }
80
+ });
81
+ },
82
+ close() {
83
+ if (closed) return;
84
+ closed = true;
85
+ if (timer !== undefined) clock.clearTimeout(timer);
86
+ for (const remove of listeners) remove();
87
+ listeners.clear();
88
+ }
89
+ });
90
+ budgets.add(budget);
91
+ if (deadline !== undefined && !controller.signal.aborted) {
92
+ const schedule = () => {
93
+ try {
94
+ budget.check();
95
+ timer = clock.setTimeout(schedule, budget.remainingMs());
96
+ } catch (_) { /* check has already revoked execution authority */ }
97
+ };
98
+ timer = clock.setTimeout(schedule, duration);
99
+ }
100
+ return budget;
101
+ }
102
+
103
+ module.exports = { createRequestBudget, normalizeRequestDuration };