@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.
@@ -12,6 +12,7 @@ const {
12
12
 
13
13
  const RESULT_DATA = new WeakMap();
14
14
  const FETCH_RESPONSE_DATA = new WeakMap();
15
+ const FETCH_RESPONSE_CANCELLATIONS = new WeakMap();
15
16
  const RESPONSE_HEADER_DATA = new WeakMap();
16
17
  const RESPONSE_BODY_CLASS_DATA = new WeakMap();
17
18
  const RESULT_BRAND = Symbol('pulse.runtime.result');
@@ -180,6 +181,24 @@ function claimFetchResponse(data, mode) {
180
181
  data.ownershipMode = mode;
181
182
  }
182
183
 
184
+ function retainFetchResponseForBudget(response, budget, onRelease) {
185
+ if (!(response instanceof Response) || !response.body) return;
186
+ const remove = budget?.onAbort(() => cancelResponseBody(response, budget.signal.reason));
187
+ let registrations = FETCH_RESPONSE_CANCELLATIONS.get(response);
188
+ if (!registrations) {
189
+ registrations = new Set();
190
+ FETCH_RESPONSE_CANCELLATIONS.set(response, registrations);
191
+ }
192
+ registrations.add(() => { remove?.(); onRelease?.(); });
193
+ }
194
+
195
+ function releaseFetchResponseCancellation(response) {
196
+ const registrations = FETCH_RESPONSE_CANCELLATIONS.get(response);
197
+ if (!registrations) return;
198
+ FETCH_RESPONSE_CANCELLATIONS.delete(response);
199
+ for (const remove of registrations) remove();
200
+ }
201
+
183
202
  function readerForFetchData(data, options = {}) {
184
203
  claimFetchResponse(data, 'structured-projection');
185
204
  if (data.bodyReader) return data.bodyReader;
@@ -191,7 +210,10 @@ function readerForFetchData(data, options = {}) {
191
210
  label: 'fetched response',
192
211
  maxBytes: options.maxBodyBytes === undefined ? data.maxBodyBytes : options.maxBodyBytes,
193
212
  forceStructured: true,
194
- signal: options.signal || data.signal
213
+ signal: options.signal || data.signal,
214
+ onReadSettled: () => {
215
+ if (data.response.bodyUsed && !data.response.body?.locked) releaseFetchResponseCancellation(data.response);
216
+ }
195
217
  });
196
218
  return data.bodyReader;
197
219
  }
@@ -355,9 +377,11 @@ function projectFetchResponse(value, projection, schemaId, options = {}) {
355
377
  );
356
378
  }
357
379
 
358
- async function cancelResponseBody(response) {
359
- if (!(response instanceof Response) || !response.body || response.bodyUsed) return;
360
- try { await response.body.cancel(); } catch (_) { /* ownership cleanup is best effort */ }
380
+ function cancelResponseBody(response, reason) {
381
+ if (!(response instanceof Response) || !response.body || response.body.locked) return;
382
+ // A provider's cancellation callback may reject or never settle. Cleanup must
383
+ // observe rejection without delaying request completion or decoding the body.
384
+ try { void response.body.cancel(reason).catch(() => {}); } catch (_) { /* best effort */ }
361
385
  }
362
386
 
363
387
  async function fetchResponseToResponse(value, requestMethod = 'GET') {
@@ -371,7 +395,7 @@ async function fetchResponseToResponse(value, requestMethod = 'GET') {
371
395
  if (bodyAllowed && !statusChanged) {
372
396
  return rememberResponseMetadata(data.response, headers, value.bodyClass);
373
397
  }
374
- if (!bodyAllowed) await cancelResponseBody(data.response);
398
+ if (!bodyAllowed) cancelResponseBody(data.response);
375
399
  return rememberResponseMetadata(
376
400
  new Response(bodyAllowed ? data.response.body : null, {
377
401
  status: value.status,
@@ -394,6 +418,7 @@ async function fetchResponseToResponse(value, requestMethod = 'GET') {
394
418
  module.exports = Object.freeze({
395
419
  PulseRuntimeContractError,
396
420
  PulseUnhandledError,
421
+ cancelResponseBody,
397
422
  createJsonResult,
398
423
  createOpaqueFetchResponse,
399
424
  createResponseResult,
@@ -403,6 +428,8 @@ module.exports = Object.freeze({
403
428
  isPulseResult,
404
429
  markOpaqueResponse,
405
430
  normalizeFetchResponse,
431
+ retainFetchResponseForBudget,
432
+ releaseFetchResponseCancellation,
406
433
  normalizeHeaders,
407
434
  projectFetchResponse,
408
435
  responseAllowsBody,
@@ -1,12 +1,15 @@
1
1
  'use strict';
2
+ const { createRequestBudget } = require('./request-budget.js');
2
3
 
3
4
  const { compileRoutePath, matchRoutePath, normalizeRoutePath } = require('./path.js');
4
5
  const { createContext, createRequestView } = require('./context.js');
5
6
  const { createJavascriptEffectExecution } = require('./effect-adapter.js');
6
7
  const { reportingLevel } = require('./logging.js');
8
+ const { isApplicationError } = require('./errors.js');
7
9
  const {
8
10
  PulseRuntimeContractError,
9
11
  PulseUnhandledError,
12
+ cancelResponseBody,
10
13
  fetchResponseToResponse,
11
14
  isPulseFetchResponse,
12
15
  isPulseResult,
@@ -31,6 +34,17 @@ function requireRouter(value) {
31
34
  return value;
32
35
  }
33
36
 
37
+ function mountEligibility(value) {
38
+ if (value === undefined) return undefined;
39
+ const invalid = () => { throw new TypeError('Pulse Router mount eligibility requires { state: "key", equals: "value" } with string data properties.'); };
40
+ if (!value || typeof value !== 'object') return invalid();
41
+ const properties = Object.getOwnPropertyDescriptors(value);
42
+ if (Reflect.ownKeys(properties).length !== 2 ||
43
+ !properties.state || !properties.equals ||
44
+ typeof properties.state.value !== 'string' || typeof properties.equals.value !== 'string') return invalid();
45
+ return Object.freeze({ state: properties.state.value, equals: properties.equals.value });
46
+ }
47
+
34
48
  function mergeParams(left, right) {
35
49
  return Object.freeze({ ...(left || {}), ...(right || {}) });
36
50
  }
@@ -111,10 +125,11 @@ class Router {
111
125
  return this;
112
126
  }
113
127
 
114
- mount(path, router) {
128
+ mount(path, router, eligibility) {
115
129
  ROUTER_STATE.get(this).entries.push(entry('mount', {
116
130
  path: compileRoutePath(path, { scoped: true, allowWildcard: true }),
117
- router: requireRouter(router)
131
+ router: requireRouter(router),
132
+ eligibility: mountEligibility(eligibility)
118
133
  }));
119
134
  return this;
120
135
  }
@@ -159,7 +174,7 @@ function redactedError(frame, error) {
159
174
 
160
175
  function containUnexpected(error, frame) {
161
176
  const safe = redactedError(frame, error);
162
- return safe instanceof PulseUnhandledError ? safe : new PulseUnhandledError(safe);
177
+ return isApplicationError(error) || safe instanceof PulseUnhandledError ? safe : new PulseUnhandledError(safe);
163
178
  }
164
179
 
165
180
  function contractError(code, message) {
@@ -171,9 +186,7 @@ async function normalizeHandlerResponse(value, requestMethod) {
171
186
  if (isPulseFetchResponse(value)) return fetchResponseToResponse(value, requestMethod);
172
187
  if (value instanceof Response) {
173
188
  if (String(requestMethod).toUpperCase() !== 'HEAD' && statusAllowsBody(value.status)) return value;
174
- if (value.body && !value.bodyUsed) {
175
- try { await value.body.cancel(); } catch (_) { /* ownership cleanup is best effort */ }
176
- }
189
+ cancelResponseBody(value);
177
190
  const response = new Response(null, { status: value.status, statusText: value.statusText, headers: value.headers });
178
191
  return responseBodyClass(value) === 'opaque'
179
192
  ? markOpaqueResponse(response, responseHeaderPairs(value))
@@ -192,6 +205,7 @@ async function dispatchRouter(router, frame, startIndex, activeError) {
192
205
  let error = activeError;
193
206
 
194
207
  while (index < entries.length) {
208
+ frame.signal?.throwIfAborted();
195
209
  const current = entries[index];
196
210
 
197
211
  if (error !== NO_ERROR) {
@@ -215,7 +229,7 @@ async function dispatchRouter(router, frame, startIndex, activeError) {
215
229
 
216
230
  if (current.kind === 'mount') {
217
231
  const match = matchRoutePath(current.path, frame.relativePath);
218
- if (!match) {
232
+ if (!match || (current.eligibility && frame.state.get(current.eligibility.state) !== current.eligibility.equals)) {
219
233
  index += 1;
220
234
  continue;
221
235
  }
@@ -300,10 +314,13 @@ async function runNormalHandler(_router, frame, index, handler, routeContext) {
300
314
  } catch (error) {
301
315
  if (handlerError === undefined) handlerError = error;
302
316
  }
317
+ frame.signal?.throwIfAborted();
318
+ if (handlerError !== undefined && frame.executionOptions.outputExecution?.started) throw handlerError;
303
319
  if (handlerError !== undefined) {
304
320
  return Object.freeze({ kind: 'continue', index: index + 1, error: containUnexpected(handlerError, frame), frame });
305
321
  }
306
322
 
323
+ frame.executionOptions.outputExecution?.validateResult(output);
307
324
  const transfer = transferData(output, transferFactory.token);
308
325
  if (transferFactory.wasCalled()) {
309
326
  if (!transfer || output !== transferFactory.transfer()) {
@@ -353,10 +370,13 @@ async function runErrorHandler(_router, frame, index, activeError, handler) {
353
370
  } catch (error) {
354
371
  if (handlerError === undefined) handlerError = error;
355
372
  }
373
+ frame.signal?.throwIfAborted();
374
+ if (handlerError !== undefined && frame.executionOptions.outputExecution?.started) throw handlerError;
356
375
  if (handlerError !== undefined) {
357
376
  return Object.freeze({ kind: 'continue', index: index + 1, error: containUnexpected(handlerError, frame), frame });
358
377
  }
359
378
 
379
+ frame.executionOptions.outputExecution?.validateResult(output);
360
380
  const transfer = transferData(output, transferFactory.token);
361
381
  if (transferFactory.wasCalled()) {
362
382
  if (!transfer || output !== transferFactory.transfer()) {
@@ -409,53 +429,63 @@ async function executeRouter(router, request, options = {}) {
409
429
  // Logging evidence is best effort.
410
430
  }
411
431
  }
412
- const executionSignal = options.signal || request.signal;
413
- const effectExecution = createJavascriptEffectExecution({
414
- effectAdapter: options.effectAdapter,
415
- capabilities: options.capabilities,
416
- application: options.application,
417
- request,
418
- executionKind: 'request',
419
- signal: executionSignal,
420
- maxEffects: options.maxEffects,
421
- kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
422
- maxBindingNameBytes: options.maxBindingNameBytes,
423
- maxBindingValueBytes: options.maxBindingValueBytes,
424
- maxKvNamespaceBytes: options.maxKvNamespaceBytes,
425
- maxKvKeyBytes: options.maxKvKeyBytes,
426
- maxKvValueBytes: options.maxKvValueBytes,
427
- maxKvValueDepth: options.maxKvValueDepth,
428
- maxKvValueEntries: options.maxKvValueEntries,
429
- schemaCodecs: options.schemaCodecs,
430
- strict: options.strict,
431
- target: options.target,
432
- provider: options.provider,
433
- redactionValues: options.redactionValues,
434
- onEffectObservation: options.onEffectObservation
435
- });
436
- const req = createRequestView(request, options.requestHeaders, effectExecution, {
437
- ...options,
438
- signal: executionSignal,
439
- redactJsonTraceValue(value) { return effectExecution.redactValue(value); }
440
- });
441
- const executionOptions = Object.freeze({
442
- ...options,
443
- signal: executionSignal,
444
- redactJsonTraceValue(value) { return effectExecution.redactValue(value); }
445
- });
446
- const frame = {
447
- request,
448
- req,
449
- relativePath: normalizeRoutePath(req.path),
450
- params: Object.freeze({}),
451
- state: new Map(),
452
- effectExecution,
453
- signal: executionSignal,
454
- executionOptions
455
- };
432
+ const ownsBudget = options.requestBudget === undefined;
433
+ const budget = createRequestBudget({ ...options, requestSignal: request.signal });
434
+ options = { ...options, requestBudget: budget, signal: budget.signal, deadlineMonotonicMs: budget.deadlineMonotonicMs ?? options.deadlineMonotonicMs, kvClock: budget.deadlineMonotonicMs === undefined ? options.kvClock : budget.clock };
435
+ const executionSignal = budget.signal;
436
+ let effectExecution;
437
+ let transferredResponse;
456
438
  try {
457
- const result = await dispatchRouter(router, frame, 0, NO_ERROR);
458
- if (result.kind === 'response') return result.response;
439
+ budget.check();
440
+ effectExecution = createJavascriptEffectExecution({
441
+ outputExecution: options.outputExecution,
442
+ effectAdapter: options.effectAdapter,
443
+ capabilities: options.capabilities,
444
+ application: options.application,
445
+ request,
446
+ executionKind: 'request',
447
+ signal: executionSignal,
448
+ requestBudget: budget,
449
+ maxEffects: options.maxEffects,
450
+ kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
451
+ maxBindingNameBytes: options.maxBindingNameBytes,
452
+ maxBindingValueBytes: options.maxBindingValueBytes,
453
+ maxKvNamespaceBytes: options.maxKvNamespaceBytes,
454
+ maxKvKeyBytes: options.maxKvKeyBytes,
455
+ maxKvValueBytes: options.maxKvValueBytes,
456
+ maxKvValueDepth: options.maxKvValueDepth,
457
+ maxKvValueEntries: options.maxKvValueEntries,
458
+ schemaCodecs: options.schemaCodecs,
459
+ strict: options.strict,
460
+ target: options.target,
461
+ provider: options.provider,
462
+ redactionValues: options.redactionValues,
463
+ onEffectObservation: options.onEffectObservation
464
+ });
465
+ const req = createRequestView(request, options.requestHeaders, effectExecution, {
466
+ ...options,
467
+ signal: executionSignal,
468
+ redactJsonTraceValue(value) { return effectExecution.redactValue(value); }
469
+ });
470
+ const executionOptions = Object.freeze({
471
+ ...options,
472
+ signal: executionSignal,
473
+ redactJsonTraceValue(value) { return effectExecution.redactValue(value); }
474
+ });
475
+ const frame = {
476
+ request,
477
+ req,
478
+ relativePath: normalizeRoutePath(req.path),
479
+ params: Object.freeze({}),
480
+ state: new Map(),
481
+ effectExecution,
482
+ signal: executionSignal,
483
+ executionOptions
484
+ };
485
+ budget.check();
486
+ const result = await budget.race(dispatchRouter(router, frame, 0, NO_ERROR));
487
+ executionSignal?.throwIfAborted();
488
+ if (result.kind === 'response') return (transferredResponse = result.response);
459
489
  if (result.error !== NO_ERROR) {
460
490
  return new Response('Internal Server Error', {
461
491
  status: 500,
@@ -468,9 +498,10 @@ async function executeRouter(router, request, options = {}) {
468
498
  });
469
499
  } finally {
470
500
  try {
471
- await effectExecution.close();
501
+ await effectExecution?.close(transferredResponse);
472
502
  } finally {
473
- if (typeof options.onEffectSummary === 'function') options.onEffectSummary(effectExecution.summary());
503
+ if (ownsBudget) budget.close();
504
+ if (effectExecution && typeof options.onEffectSummary === 'function') options.onEffectSummary(effectExecution.summary());
474
505
  }
475
506
  }
476
507
  }
@@ -144,7 +144,17 @@ function utf8Bytes(value) {
144
144
 
145
145
  function assertSchemaBodySize(options, text, schemaId, source) {
146
146
  const configured = Number(schemaRegistry(options)?.maxBytes);
147
- const maxBytes = Number.isSafeInteger(configured) && configured > 0 ? configured : 65_536;
147
+ let maxBytes = Number.isSafeInteger(configured) && configured > 0 ? configured : 65_536;
148
+ const jsonLimits = schemaCodecs(options)?.schema?.(schemaId)?.jsonLimits;
149
+ if (jsonLimits) {
150
+ maxBytes = Math.min(maxBytes, jsonLimits.maxTextBytes);
151
+ // UTF-16 length is a lower bound on UTF-8 bytes. Reject large application
152
+ // strings before TextEncoder allocates their complete encoded form.
153
+ if (typeof text === 'string' && text.length > maxBytes) {
154
+ throw schemaRuntimeError('PULSE_BODY_TOO_LARGE', `Pulse ${source} schema body exceeds the ${maxBytes} byte limit.`,
155
+ { schemaId: String(schemaId), source, bytesAtLeast: text.length, maxBytes });
156
+ }
157
+ }
148
158
  const bytes = utf8Bytes(text);
149
159
  if (bytes <= maxBytes) return bytes;
150
160
  throw schemaRuntimeError(
@@ -187,6 +197,7 @@ function emitSchemaTrace(options, event, value) {
187
197
 
188
198
  function decodeSchemaText(schemaId, text, options = {}, context = {}) {
189
199
  const source = String(context.source || 'json');
200
+ const applicationText = source === 'application-text';
190
201
  const id = requireSchemaId(schemaId, options, source);
191
202
  if (id === undefined) {
192
203
  try {
@@ -197,24 +208,28 @@ function decodeSchemaText(schemaId, text, options = {}, context = {}) {
197
208
  }
198
209
  let contentType = normalizeContentType(headerValue(context.headers, 'content-type'));
199
210
  const event = {
200
- boundary: source === 'request' ? 'request' : 'fetch-response',
211
+ boundary: applicationText ? source : source === 'request' ? 'request' : 'fetch-response',
201
212
  schemaId: id,
202
213
  responseCaseId: null,
203
214
  operationId: String(context.operationId || `${source}:${id}`),
204
215
  status: context.status ?? null,
205
216
  headers: context.headers || [],
206
- bodyOwnership: source === 'request' ? 'request-snapshot' : 'fetched-response-snapshot',
217
+ bodyOwnership: applicationText ? 'application-owned' : source === 'request' ? 'request-snapshot' : 'fetched-response-snapshot',
207
218
  effectId: context.effectId || null,
208
219
  groupId: context.groupId || null
209
220
  };
210
221
  try {
211
- contentType = assertSchemaContentType(options, context.headers, id, source);
222
+ if (applicationText && typeof text !== 'string') {
223
+ throw schemaRuntimeError('PULSE_SCHEMA_DECODE', 'Application JSON text must be a string.',
224
+ { schemaId: id, source, expected: 'string', actualKind: typeof text });
225
+ }
226
+ if (!applicationText) contentType = assertSchemaContentType(options, context.headers, id, source);
212
227
  assertSchemaBodySize(options, text, id, source);
213
228
  const codecs = requireSchemaCodecs(options, id, source);
214
229
  const value = codecs.decodeJsonText(id, String(text), source);
215
230
  emitSchemaTrace(options, {
216
231
  ...event,
217
- kind: source === 'request' ? 'json.decode.request' : 'json.decode.fetch',
232
+ kind: applicationText ? 'json.decode.text' : source === 'request' ? 'json.decode.request' : 'json.decode.fetch',
218
233
  contentType: contentType || null
219
234
  }, value);
220
235
  return value;
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const { PulseRuntimeContractError } = require('./errors.js');
4
+
5
+ // UTC years 1970–9999; integer milliseconds remain exactly representable.
6
+ const WALL_TIME_MAX_MS = 253402300799999;
7
+ const unavailable = Object.freeze({ status: 'failed', reason: 'unavailable' });
8
+ const invalid = Object.freeze({ status: 'failed', reason: 'invalid-clock' });
9
+
10
+ function validMilliseconds(value) {
11
+ return Number.isSafeInteger(value) && value >= 0 && value <= WALL_TIME_MAX_MS;
12
+ }
13
+
14
+ /** The provider supplies the clock. Formatting never samples ambient time. */
15
+ function readWallTime(clock) {
16
+ if (typeof clock !== 'function') return unavailable;
17
+ let value;
18
+ try { value = clock(); } catch { return unavailable; }
19
+ if (!validMilliseconds(value)) return invalid;
20
+ return Object.freeze({ status: 'ok', unixEpochMs: value === 0 ? 0 : value, iso8601: new Date(value).toISOString() });
21
+ }
22
+
23
+ /** Validate and detach a trusted adapter result before guest/application use. */
24
+ function normalizeTimeResult(input) {
25
+ const fail = () => { throw new PulseRuntimeContractError('PULSE_TIME_RESULT_INVALID', 'Pulse time provider returned an invalid result.'); };
26
+ if (!input || typeof input !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(input))) return fail();
27
+ const properties = Object.getOwnPropertyDescriptors(input);
28
+ const keys = Reflect.ownKeys(properties);
29
+ if (keys.some(key => typeof key !== 'string' || !Object.hasOwn(properties[key], 'value'))) return fail();
30
+ const status = properties.status?.value;
31
+ if (status === 'failed' && keys.length === 2 && properties.reason
32
+ && ['unavailable', 'invalid-clock'].includes(properties.reason.value)) {
33
+ return properties.reason.value === 'unavailable' ? unavailable : invalid;
34
+ }
35
+ const ms = properties.unixEpochMs?.value;
36
+ if (status !== 'ok' || keys.length !== 3 || !validMilliseconds(ms)
37
+ || properties.iso8601?.value !== new Date(ms).toISOString()) return fail();
38
+ return Object.freeze({ status: 'ok', unixEpochMs: ms === 0 ? 0 : ms, iso8601: properties.iso8601.value });
39
+ }
40
+
41
+ module.exports = Object.freeze({ WALL_TIME_MAX_MS, readWallTime, normalizeTimeResult });