@pulse-compute/runtime 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
@@ -7,9 +7,9 @@
7
7
  > **Supported entry points:** `@pulse-compute/runtime`<br>
8
8
  > **Stability:** Supported application authoring and execution contract.<br>
9
9
  > **npm:** [`@pulse-compute/runtime`](https://www.npmjs.com/package/@pulse-compute/runtime)<br>
10
- > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.4/packages/runtime/)
10
+ > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.5/packages/runtime/)
11
11
  >
12
- > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.4` package policy.
12
+ > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.5` package policy.
13
13
  <!-- pulse-package-status:end -->
14
14
 
15
15
  `@pulse-compute/runtime` is the low-level, provider-neutral Pulse application contract.
package/docs/API.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Pulse runtime contract
2
2
 
3
3
  For source eligibility, use [Managed handler TypeScript and
4
- JavaScript](https://pulsecompute.io/v1.0.0-beta.4/reference/handler-authoring/). For provider and target
4
+ JavaScript](https://pulsecompute.io/v1.0.0-beta.5/reference/handler-authoring/). For provider and target
5
5
  differences, use the [compatibility
6
- matrix](https://pulsecompute.io/v1.0.0-beta.4/reference/compatibility-matrix/). For CLI and runtime failures,
6
+ matrix](https://pulsecompute.io/v1.0.0-beta.5/reference/compatibility-matrix/). For CLI and runtime failures,
7
7
  use the stable codes in the [diagnostics
8
- reference](https://pulsecompute.io/v1.0.0-beta.4/reference/diagnostics/).
8
+ reference](https://pulsecompute.io/v1.0.0-beta.5/reference/diagnostics/).
9
9
 
10
10
  This document describes the provider-neutral TypeScript application contract compiled by Pulse. Low-level authoring types and the static `Router` come from `@pulse-compute/runtime`. The public `@pulse-compute/pulse` package owns the conventional `Pulse` application root, deferred project configuration, and schema declarations, while `@pulse-compute/cli` owns workspace orchestration.
11
11
 
@@ -25,9 +25,11 @@ process, provider SDK, or global network surface behind it.
25
25
  | [`ctx.req.header(name)`](#request-metadata-and-headers) | HTTP handlers and middleware | Case-insensitive first-value header lookup. |
26
26
  | [`ctx.param(name)`](#ctxparam) | Matched route handlers | Named parameters from the static route pattern. |
27
27
  | [`ctx.state`](#ctxstate) | HTTP and event handlers | Invocation-local string state shared across one execution. |
28
+ | [`ctx.time.now()`](#ctxtime) | HTTP and event handlers | Provider wall-clock sample with matching Unix milliseconds and UTC text. |
28
29
  | [`ctx.fetch`](#ctxfetch) | HTTP and event handlers | Explicit outbound HTTP effect and structured or opaque response ownership. |
29
30
  | [`ctx.parallel`](#ctxparallel) | HTTP and event handlers | Statically keyed concurrent Pulse effects. |
30
31
  | [`ctx.encodeJson`](#ctxencodejson) | HTTP and event handlers | Synchronous schema-bound, size-limited JSON text. |
32
+ | [`ctx.decodeJson`](#ctxdecodejson) | HTTP and event handlers | Synchronous schema-bound decode of bounded application text. |
31
33
  | [`ctx.emit`](#ctxemit) | HTTP and event handlers | One-way, schema-bound event acceptance effect. |
32
34
  | [`ctx.log`](#ctxlog) | HTTP and event handlers | Synchronous thresholded logging. |
33
35
  | [`ctx.config`, `ctx.secret`](#config-and-secrets) | HTTP and event handlers | Explicit configured binding reads. |
@@ -50,7 +52,7 @@ Every managed handler is async-shaped:
50
52
  type Handler = (ctx: PulseContext) => Promise<PulseResult | PulseFetchResponse>
51
53
  ```
52
54
 
53
- For native targets the compiler erases the async wrapper. Awaited Pulse effects lower into the existing explicit effect and continuation state machine; no Promise runtime or Asyncify transform is linked. Awaiting a proven synchronous `ctx` expression is redundant and may warn, while arbitrary non-Pulse awaits mark the native eligibility boundary. The canonical [handler authoring reference](https://pulsecompute.io/v1.0.0-beta.4/reference/handler-authoring/) defines the static language subset; the [compatibility matrix](https://pulsecompute.io/v1.0.0-beta.4/reference/compatibility-matrix/) owns the tested four-mode claims.
55
+ For native targets the compiler erases the async wrapper. Awaited Pulse effects lower into the existing explicit effect and continuation state machine; no Promise runtime or Asyncify transform is linked. Awaiting a proven synchronous `ctx` expression is redundant and may warn, while arbitrary non-Pulse awaits mark the native eligibility boundary. The canonical [handler authoring reference](https://pulsecompute.io/v1.0.0-beta.5/reference/handler-authoring/) defines the static language subset; the [compatibility matrix](https://pulsecompute.io/v1.0.0-beta.5/reference/compatibility-matrix/) owns the tested four-mode claims.
54
56
 
55
57
  ## Static `Router`
56
58
 
@@ -138,9 +140,9 @@ eligibility. Fastly targets fail closed because no event ingress/emit adapter is
138
140
  claimed. There is no public event injection command, no event-aware development
139
141
  listener, no HTTP/GRIP translation, and no automatic target fallback.
140
142
 
141
- The [static events guide](https://pulsecompute.io/v1.0.0-beta.4/guides/events/) owns the complete frame,
143
+ The [static events guide](https://pulsecompute.io/v1.0.0-beta.5/guides/events/) owns the complete frame,
142
144
  queue, target-eligibility, diagnostic, and Native-extension contract. The
143
- source-bound [event example](https://pulsecompute.io/v1.0.0-beta.4/examples/11-events/) runs the same mixed project
145
+ source-bound [event example](https://pulsecompute.io/v1.0.0-beta.5/examples/11-events/) runs the same mixed project
144
146
  on Node JavaScript and Node Native.
145
147
 
146
148
  ## `ctx.req`
@@ -382,6 +384,16 @@ return ctx.json(output, {
382
384
  })
383
385
  ```
384
386
 
387
+ ## `ctx.time`
388
+
389
+ `await ctx.time.now()` returns one provider-owned wall-clock sample. On success,
390
+ `status` is `'ok'`, `unixEpochMs` is integer Unix milliseconds and `iso8601` is
391
+ its matching UTC string. A failed sample has `status: 'failed'` and reason
392
+ `'unavailable'` or `'invalid-clock'`. Direct awaited calls and keyed parallel
393
+ members are supported on Node/Fastly Native and JavaScript. Wall time can regress
394
+ and is separate from monotonic deadlines and exact commit timestamps. See the
395
+ [complete result contract](https://pulsecompute.io/v1.0.0-beta.5/packages/runtime/#wall-time).
396
+
385
397
  ## Config and secrets
386
398
 
387
399
  ```ts
@@ -453,7 +465,20 @@ projects the value, then returns detached JSON text bounded by `schemas.maxBytes
453
465
  in UTF-8 bytes. It does not create a response or dispatch an effect. Invalid
454
466
  values and oversized text fail before subsequent writes. Declaration order and
455
467
  array order are preserved; cross-target parity is semantic, not a universal
456
- canonical-byte format. See [application-owned encoding](https://pulsecompute.io/v1.0.0-beta.4/guides/json-schemas/#encode-application-owned-text).
468
+ canonical-byte format. See [application-owned encoding](https://pulsecompute.io/v1.0.0-beta.5/guides/json-schemas/#encode-application-owned-text).
469
+
470
+ ## `ctx.decodeJson`
471
+
472
+ ```ts
473
+ const candidate = ctx.decodeJson<Candidate>(stored.text, 'app.Candidate')
474
+ ```
475
+
476
+ Requires a literal registered schema even in non-strict mode. The input must
477
+ be a string within `schemas.maxBytes` UTF-8 bytes. Decoding returns a detached,
478
+ deeply immutable value with declared fields only. It performs no effect and
479
+ does not apply HTTP content-type policy. Keep the original text for hashing,
480
+ verification and retries; decoding does not establish byte canonicalization or
481
+ storage acceptance. See [application text decoding](https://pulsecompute.io/v1.0.0-beta.5/guides/json-schemas/#decode-application-owned-text).
457
482
 
458
483
  ## Explicit JSON schemas
459
484
 
@@ -496,12 +521,12 @@ forms lower into canonical package operations; JavaScript targets execute the
496
521
  real package implementation.
497
522
 
498
523
  Older `/pulsewasm` imports are compatibility-only and are isolated in the
499
- [migration guide](https://pulsecompute.io/v1.0.0-beta.4/guides/compatibility-imports/). See the
500
- [GRIP package guide](https://pulsecompute.io/v1.0.0-beta.4/packages/grip/) for the complete current surface.
524
+ [migration guide](https://pulsecompute.io/v1.0.0-beta.5/guides/compatibility-imports/). See the
525
+ [GRIP package guide](https://pulsecompute.io/v1.0.0-beta.5/packages/grip/) for the complete current surface.
501
526
 
502
527
  ## Entities API
503
528
 
504
- `@pulse-compute/entities` is part of the synchronized `1.0.0-beta.4` package
529
+ `@pulse-compute/entities` is part of the synchronized `1.0.0-beta.5` package
505
530
  set. The application surface has two runtime values:
506
531
 
507
532
  ```ts
@@ -563,9 +588,9 @@ Registrations and the terminal binding must use the supported static form. The
563
588
  first-party JSON-RPC adapter accepts bounded JSON-RPC 2.0 request objects and
564
589
  named params, validates declared schemas, uses stable error framing, and
565
590
  acknowledges notifications with HTTP `204`. See the [package
566
- guide](https://pulsecompute.io/v1.0.0-beta.4/packages/entities/), [entity/adapter
567
- model](https://pulsecompute.io/v1.0.0-beta.4/concepts/entities-and-adapters/), and [executable
568
- example](https://pulsecompute.io/v1.0.0-beta.4/examples/10-entities-tools/).
591
+ guide](https://pulsecompute.io/v1.0.0-beta.5/packages/entities/), [entity/adapter
592
+ model](https://pulsecompute.io/v1.0.0-beta.5/concepts/entities-and-adapters/), and [executable
593
+ example](https://pulsecompute.io/v1.0.0-beta.5/examples/10-entities-tools/).
569
594
 
570
595
  ## Project workflow
571
596
 
@@ -582,5 +607,5 @@ selected profile, handler entry, schema declarations, provider bindings, and
582
607
  output directory through `.pulse/config.ts`. `pulse inspect` is optional
583
608
  observability, and `pulse compile` is the advanced provider-neutral Native
584
609
  artifact command; neither is required before `pulse build`. See the [project
585
- lifecycle guide](https://pulsecompute.io/v1.0.0-beta.4/guides/project-lifecycle/) and [CLI
586
- reference](https://pulsecompute.io/v1.0.0-beta.4/reference/cli/).
610
+ lifecycle guide](https://pulsecompute.io/v1.0.0-beta.5/guides/project-lifecycle/) and [CLI
611
+ reference](https://pulsecompute.io/v1.0.0-beta.5/reference/cli/).
@@ -1,6 +1,6 @@
1
1
  # Static Router authoring
2
2
 
3
- Use `Router` when an application has several method/path entry points or needs compile-time middleware. It is a static authoring marker from `@pulse-compute/runtime`, not a JavaScript runtime dispatcher. The shared async/static restrictions are defined in [Managed handler TypeScript and JavaScript](https://pulsecompute.io/v1.0.0-beta.4/reference/handler-authoring/); target claims live in the [compatibility matrix](https://pulsecompute.io/v1.0.0-beta.4/reference/compatibility-matrix/).
3
+ Use `Router` when an application has several method/path entry points or needs compile-time middleware. It is a static authoring marker from `@pulse-compute/runtime`, not a JavaScript runtime dispatcher. The shared async/static restrictions are defined in [Managed handler TypeScript and JavaScript](https://pulsecompute.io/v1.0.0-beta.5/reference/handler-authoring/); target claims live in the [compatibility matrix](https://pulsecompute.io/v1.0.0-beta.5/reference/compatibility-matrix/).
4
4
 
5
5
  <!-- pulse-doc-source: examples/09-router-lowering/src/index.ts -->
6
6
  ```ts
@@ -146,6 +146,34 @@ app.error(async (error, ctx, next) => {
146
146
 
147
147
  If the normal lane is exhausted, Pulse returns `404 Not Found`. If the error lane is exhausted, Pulse returns `500 Internal Server Error`.
148
148
 
149
+ Request UTF-8 failures, schema data failures and JWT validation failures enter the next registered
150
+ error handler. Branch on `error.code`; the portable contract does not require
151
+ identical messages, stacks, causes or detail fields across providers.
152
+
153
+ | Boundary | Portable error codes |
154
+ | --- | --- |
155
+ | Request text encoding | `PULSE_REQUEST_BODY_INVALID_UTF8` (Node Native, Node JavaScript, Fastly Native) |
156
+ | Schema data | `PULSE_SCHEMA_DECODE`, `PULSE_SCHEMA_ENCODE`, `PULSE_SCHEMA_JSON_MALFORMED`, `PULSE_SCHEMA_CONTENT_TYPE`, `PULSE_BODY_TOO_LARGE` |
157
+ | JWT input and verification | `PULSE_JWT_TOKEN_REQUIRED`, `PULSE_JWT_BEARER_INVALID`, `PULSE_JWT_MALFORMED`, `PULSE_JWT_LIMIT_EXCEEDED`, `PULSE_JWT_ALGORITHM_NOT_ALLOWED`, `PULSE_JWT_KEY_INVALID`, `PULSE_JWT_SIGNATURE_INVALID` |
158
+ | JWT claims | `PULSE_JWT_CLOCK_INVALID`, `PULSE_JWT_CLAIMS_INVALID`, `PULSE_JWT_CLAIMS_SCHEMA_INVALID` |
159
+
160
+ Recovery moves forward in registration order, including through mounted
161
+ routers. A failed handler never resumes. An error handler can return a response,
162
+ forward with `return next(error)`, or clear the error lane with `return next()`.
163
+ A data failure inside an error handler transfers to a later error handler.
164
+
165
+ Already-started members of a failed effect group settle before application
166
+ recovery. Pulse does not retry them, undo completed writes, or imply that a
167
+ failed invocation had no external effects. Conditional KV results such as
168
+ `conflict`, `not-stored` and `unknown` remain ordinary outcomes for the handler to
169
+ inspect. Their meaning does not change when an error handler is registered.
170
+
171
+ Cancellation ends the invocation without an application response. On Native,
172
+ traps, provider protocol failures and unavailable capabilities remain terminal; they
173
+ do not acquire recovery or rollback guarantees. Native authoring still does not
174
+ admit arbitrary `throw` or `try`/`catch`. JavaScript retains its existing
175
+ `PulseUnhandledError` containment for unexpected handler failures.
176
+
149
177
  ## Middleware scope and effects
150
178
 
151
179
  A path-scoped `use('/api', handler)` applies only to that subtree. Middleware declared on a mounted child Router is naturally limited to the mounted subtree.
@@ -1,6 +1,6 @@
1
1
  # Beta scope
2
2
 
3
- Pulse `1.0.0-beta.4` is a Beta of one provider-neutral application contract
3
+ Pulse `1.0.0-beta.5` is a Beta of one provider-neutral application contract
4
4
  with explicit Native and JavaScript execution targets. The intended 1.0
5
5
  surface is present, but deliberate corrections may still occur before the
6
6
  stable `1.0.0` release.
@@ -18,6 +18,8 @@ targets.
18
18
  middleware, GET/HEAD/POST/PUT/PATCH/DELETE routes, exact paths, named parameters, trailing
19
19
  wildcards, and acyclic mounts.
20
20
  - Basic branching and structured object, array, and scalar manipulation.
21
+ - Literal-capped pure `for` loops and zero-argument string `.trim()` across
22
+ Native and JavaScript; see [bounded application values](https://pulsecompute.io/v1.0.0-beta.5/concepts/compilation-and-lowering/#bounded-application-values).
21
23
  - Request method, URL, path, headers, text, and JSON access.
22
24
  - Bounded, memoized structured body decoding.
23
25
  - Explicit TypeScript JSON schema declarations and literal schema IDs.
@@ -75,6 +77,9 @@ JavaScript-selected project into a Native one.
75
77
 
76
78
  ## Deliberately unsupported
77
79
 
80
+ - Effectful or unbounded loops, arbitrary callback transformations, and Native
81
+ pure-helper calls. Pure loop bodies cannot transfer from the handler.
82
+
78
83
  - Automatic fallback from Native lowering to JavaScript execution.
79
84
  - Declaring general target availability without satisfying every declared
80
85
  full-target-support gate.
@@ -106,13 +111,13 @@ JavaScript-selected project into a Native one.
106
111
  ## Compatibility authority
107
112
 
108
113
  The single source-form, provider-binding, artifact, and deployment-boundary
109
- table is [Provider and target compatibility](https://pulsecompute.io/v1.0.0-beta.4/reference/compatibility-matrix/).
114
+ table is [Provider and target compatibility](https://pulsecompute.io/v1.0.0-beta.5/reference/compatibility-matrix/).
110
115
  It uses one public target order—Node JavaScript, Fastly JavaScript, Node Native,
111
116
  and Fastly Native—and links every row to a focused proof or canonical contract.
112
117
 
113
118
  The exact portable language subset and the JavaScript-only Native eligibility
114
119
  boundaries are defined in
115
- [Managed handler TypeScript and JavaScript](https://pulsecompute.io/v1.0.0-beta.4/reference/handler-authoring/).
120
+ [Managed handler TypeScript and JavaScript](https://pulsecompute.io/v1.0.0-beta.5/reference/handler-authoring/).
116
121
 
117
122
  ## Body model
118
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulse-compute/runtime",
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"
@@ -41,7 +41,7 @@
41
41
  "url": "git+https://github.com/pulse-compute/pulse.git",
42
42
  "directory": "packages/runtime"
43
43
  },
44
- "homepage": "https://pulsecompute.io/v1.0.0-beta.4/packages/runtime/",
44
+ "homepage": "https://pulsecompute.io/v1.0.0-beta.5/packages/runtime/",
45
45
  "bugs": {
46
46
  "url": "https://github.com/pulse-compute/pulse/issues"
47
47
  }
package/src/host.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  Handler,
3
+ PulseTimeResult,
3
4
  PulseEffect,
4
5
  PulseKvGeneration, PulseKvVersionedResult, PulseKvConditionalResult,
5
6
  PulseFetchResponse,
@@ -16,6 +17,8 @@ export interface PulseJavascriptEffectHostExecution {
16
17
  readonly application?: unknown;
17
18
  readonly signal?: AbortSignal;
18
19
  readonly deadlineMonotonicMs?: number;
20
+ /** Host-only request authority; package transports recheck before dispatch. */
21
+ readonly requestBudget?: PulseRequestBudget;
19
22
  /** Adds execution-owned sensitive text to runtime redaction before provider work continues. */
20
23
  registerRedactionValue(value: string | Uint8Array): void;
21
24
  /** Applies one project-owned schema codec directly to an in-memory semantic value. */
@@ -112,6 +115,7 @@ export interface PulseRuntimeHostKvNamespace<T = unknown> {
112
115
  /** Compatibility injection shape retained while providers move to one effect adapter. */
113
116
  export interface PulseRuntimeHostCapabilities {
114
117
  prepareConditionalKv?: PulseConditionalKvPreparation;
118
+ time?(execution?: PulseJavascriptEffectHostExecution): PulseTimeResult | Promise<PulseTimeResult>;
115
119
  fetch?(
116
120
  url: string,
117
121
  init?: unknown,
@@ -199,7 +203,32 @@ export interface PulseJavascriptEffectExecution {
199
203
  close(): Promise<void>;
200
204
  }
201
205
 
206
+ export interface PulseRequestBudget {
207
+ readonly signal: AbortSignal;
208
+ readonly deadlineMonotonicMs?: number;
209
+ readonly clock: PulseKvClock;
210
+ check(): void;
211
+ onAbort(callback: () => void): () => void;
212
+ remainingMs(): number;
213
+ race<T>(value: T | PromiseLike<T>): Promise<T>;
214
+ close(): void;
215
+ }
216
+ export declare function normalizeRequestDuration(value?: number): number | undefined;
217
+ export declare function createRequestBudget(options?: {
218
+ maxDurationMs?: number;
219
+ requestBudget?: PulseRequestBudget;
220
+ requestClock?: PulseKvClock;
221
+ signal?: AbortSignal;
222
+ requestSignal?: AbortSignal;
223
+ }): PulseRequestBudget;
224
+
202
225
  export interface PulseRuntimeExecutionOptions {
226
+ /** Provider-owned total managed request budget, 1..30000 ms. Omitted preserves existing behavior. */
227
+ readonly maxDurationMs?: number;
228
+ /** Shared host budget; nested adapters must not restart or close an inherited budget. */
229
+ readonly requestBudget?: PulseRequestBudget;
230
+ /** Monotonic host clock/scheduler, never application wall time. */
231
+ readonly requestClock?: PulseKvClock;
203
232
  /** Host-only monotonic clock/scheduler injection for deterministic lifecycle evidence. */
204
233
  readonly kvClock?: PulseKvClock;
205
234
  /** Host monotonic deadline; conditional KV uses the earlier of this and 10 seconds. */
@@ -418,3 +447,10 @@ export declare function admitConditionalKv(input: Readonly<Record<string, unknow
418
447
  export declare function normalizeConditionalKvResult(effect: Readonly<{ kind: string }>, value: unknown, options?: PulseBindingValueLimits): PulseKvVersionedResult<unknown> | PulseKvConditionalResult;
419
448
  export declare function executeConditionalKv(effect: Readonly<Record<string, unknown>>, prepare: PulseConditionalKvPreparation, execution?: PulseConditionalKvExecution, options?: PulseRuntimeExecutionOptions): Promise<PulseKvVersionedResult<unknown> | PulseKvConditionalResult>;
420
449
  export declare function registerKvRedactions(effect: Readonly<Record<string, unknown>>, register?: (value: string) => void): void;
450
+ /** Whether a data failure can transfer to the next Router error handler. */
451
+ export declare function isApplicationError(error: unknown): boolean;
452
+
453
+ /** Host-only wall-clock result contract; the callback samples integer Unix milliseconds. */
454
+ export declare const WALL_TIME_MAX_MS: 253402300799999;
455
+ export declare function readWallTime(clock?: (() => number) | null): PulseTimeResult;
456
+ export declare function normalizeTimeResult(value: unknown): PulseTimeResult;
package/src/host.js CHANGED
@@ -78,6 +78,9 @@ async function executeApplication(application, request, options = {}) {
78
78
  }
79
79
 
80
80
  module.exports = Object.freeze({
81
+ ...require('./internal/time.js'),
82
+ ...require('./internal/request-budget.js'),
83
+ isApplicationError: require('./internal/errors.js').isApplicationError,
81
84
  ...require('./internal/conditional-kv.js'),
82
85
  RUNTIME_HOST_API_VERSION,
83
86
  JAVASCRIPT_EFFECT_PROTOCOL_VERSION,
package/src/index.d.ts CHANGED
@@ -133,10 +133,19 @@ export type PulseEmitEvent<Payload = unknown> =
133
133
  | Readonly<{ schema: string; payload: Payload }>
134
134
  | Readonly<{ schema: null; payload?: never }>;
135
135
 
136
+ /** One provider wall-clock sample. This is neither monotonic nor a commit timestamp. */
137
+ export type PulseTimeResult =
138
+ | { readonly status: 'ok'; readonly unixEpochMs: number; readonly iso8601: string }
139
+ | { readonly status: 'failed'; readonly reason: 'unavailable' | 'invalid-clock' };
140
+
136
141
  /** Plane-neutral authority shared by one isolated HTTP request or event invocation. */
137
142
  export interface PulseExecutionContext {
138
143
  /** Validate/project a value through a literal registered schema and return bounded JSON text. */
139
144
  encodeJson(value: unknown, schemaId: string): string;
145
+ /** Decode bounded application-owned JSON text through a literal registered schema. */
146
+ decodeJson<T = unknown>(text: string, schemaId: string): T;
147
+ /** Fresh sample at dispatch, UTC 1970–9999, integer Unix milliseconds. */
148
+ readonly time: { now(): PulseParallelEffect<PulseTimeResult> };
140
149
  readonly state: PulseState;
141
150
  readonly log: PulseLogger;
142
151
  fetch(url: string, init?: PulseFetchInit): PulseFetchOperation;
@@ -206,7 +206,13 @@ function createStructuredBodyReader(owner, options = {}) {
206
206
 
207
207
  function text() {
208
208
  assertInspectable();
209
- if (!textPromise) textPromise = bytes().then((value) => new TextDecoder().decode(value));
209
+ if (!textPromise) textPromise = bytes().then((value) => {
210
+ if (label !== 'request') return new TextDecoder().decode(value);
211
+ try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(value); }
212
+ catch (cause) {
213
+ throw bodyError('PULSE_REQUEST_BODY_INVALID_UTF8', 'Pulse request text must be well-formed UTF-8.', {}, cause);
214
+ }
215
+ });
210
216
  return textPromise;
211
217
  }
212
218
 
@@ -161,6 +161,10 @@ function createContext(frame) {
161
161
  const id = requireExplicitSchemaId(schemaId, options, 'application-value');
162
162
  return encodeSchemaValue(id, value, options, { source: 'application-value' }).text;
163
163
  },
164
+ decodeJson(text, schemaId) {
165
+ const id = requireExplicitSchemaId(schemaId, options, 'application-text');
166
+ return decodeSchemaText(id, text, options, { source: 'application-text' });
167
+ },
164
168
  ...(eventContext ? {} : { req }),
165
169
  state,
166
170
  log,
@@ -183,6 +187,12 @@ function createContext(frame) {
183
187
  });
184
188
  return createFetchOperation(effects, fetchEffect, fetchRequest, options, effectId);
185
189
  },
190
+ time: Object.freeze({
191
+ now(...args) {
192
+ if (args.length) throw new TypeError('Pulse time.now takes no arguments.');
193
+ return effects.dispatch({ kind: 'time.now', providerKind: 'time', operation: 'now', capability: 'time.wall-clock' });
194
+ }
195
+ }),
186
196
  parallel(record) { return effects.parallel(record); },
187
197
  config: Object.freeze({
188
198
  get(name) {
@@ -9,6 +9,7 @@ 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');
@@ -244,6 +245,7 @@ function createCapabilityEffectAdapter(capabilities = {}) {
244
245
  if (typeof value.fetch !== 'function') return unavailableCapability(effect);
245
246
  return value.fetch(effect.url, fetchRequestInitForHost(effect.init, execution.signal), execution);
246
247
  }
248
+ if (effect.kind === 'time.now') return typeof value.time === 'function' ? value.time(execution) : readWallTime();
247
249
  if (effect.kind === 'config.get') {
248
250
  if (typeof value.config !== 'function') return unavailableCapability(effect);
249
251
  return value.config(effect.name, execution);
@@ -305,6 +307,7 @@ function normalizeEffectDescriptor(input, limits) {
305
307
  }
306
308
 
307
309
  function normalizeEffectResult(descriptor, value, limits) {
310
+ if (descriptor.kind === 'time.now') return normalizeTimeResult(value);
308
311
  if (descriptor.kind === 'config.get' || descriptor.kind === 'secret.get') {
309
312
  return normalizeBindingValue(descriptor.kind, descriptor.name, value, limits);
310
313
  }
@@ -490,6 +493,8 @@ function createJavascriptEffectExecution(options = {}) {
490
493
  event: options.event,
491
494
  application: options.application,
492
495
  signal: lifecycleController.signal,
496
+ deadlineMonotonicMs: options.deadlineMonotonicMs,
497
+ requestBudget: options.requestBudget,
493
498
  registerRedactionValue(value) {
494
499
  if (typeof value === 'string' && value.length > 0) redaction.add(value);
495
500
  },
@@ -541,6 +546,7 @@ function createJavascriptEffectExecution(options = {}) {
541
546
  'Pulse effect execution is already closed.'
542
547
  );
543
548
  }
549
+ options.requestBudget?.check();
544
550
  }
545
551
 
546
552
  function trackEffect(effect, data) {
@@ -597,20 +603,29 @@ function createJavascriptEffectExecution(options = {}) {
597
603
  const operationSignal = createEffectSignal(lifecycleController.signal, descriptor);
598
604
  const operationExecution = Object.freeze({ ...externalExecution, signal: operationSignal.signal });
599
605
  const raw = raceWithSignal(Promise.resolve().then(() => {
606
+ options.requestBudget?.check();
600
607
  if (operationSignal.signal.aborted) throw abortedEffectError(operationSignal.signal.reason);
601
608
  if (conditionalKv.isConditionalKv(kind)) return conditionalKv.executeConditionalKv(descriptor,
602
609
  adapter.prepareConditionalKv || ((_admitted, kvExecution) => () => adapter.dispatch(descriptor, kvExecution)),
603
610
  { ...operationExecution, onKvObservation: observe }, limits);
604
- return adapter.dispatch(descriptor, operationExecution);
611
+ return Promise.resolve(adapter.dispatch(descriptor, operationExecution)).then(value => {
612
+ if (value instanceof Response && value.body && options.requestBudget) {
613
+ options.requestBudget.onAbort(() => {
614
+ if (!value.body.locked) void value.body.cancel(options.requestBudget.signal.reason).catch(() => {});
615
+ });
616
+ }
617
+ return value;
618
+ });
605
619
  }), operationSignal.signal).then(
606
620
  (value) => {
621
+ options.requestBudget?.check();
607
622
  const normalized = normalizeEffectResult(descriptor, value, limits);
608
623
  if (descriptor.kind === 'secret.get' && typeof normalized === 'string') redaction.add(normalized);
609
624
  return normalized;
610
625
  },
611
626
  (error) => { throw redaction.redactError(error); }
612
627
  ).finally(operationSignal.dispose);
613
- const projected = (typeof projector === 'function' ? raw.then(projector) : raw).catch((error) => {
628
+ 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
629
  throw redaction.redactError(error);
615
630
  });
616
631
  const effectData = {
@@ -672,9 +687,10 @@ function createJavascriptEffectExecution(options = {}) {
672
687
  const publicDescriptor = publicEffectDescriptor(descriptor, redaction);
673
688
  observe({ type: 'effect-dispatched', effect: publicDescriptor });
674
689
  const promise = Promise.resolve().then(() => {
690
+ options.requestBudget?.check();
675
691
  if (lifecycleController.signal.aborted) throw abortedEffectError(lifecycleController.signal.reason);
676
692
  return producer(externalExecution);
677
- }).catch((error) => { throw redaction.redactError(error); });
693
+ }).then(value => { options.requestBudget?.check(); return value; }).catch((error) => { throw redaction.redactError(error); });
678
694
  const effectData = {
679
695
  execution,
680
696
  id,
@@ -711,7 +727,7 @@ function createJavascriptEffectExecution(options = {}) {
711
727
  : 'Pulse effect projections require a request-owned Pulse effect.'
712
728
  );
713
729
  }
714
- const projected = raceWithSignal(parent.then(projector), lifecycleController.signal).catch((error) => {
730
+ const projected = raceWithSignal(parent.then(value => { options.requestBudget?.check(); return projector(value); }).then(value => { options.requestBudget?.check(); return value; }), lifecycleController.signal).catch((error) => {
715
731
  throw redaction.redactError(error);
716
732
  });
717
733
  const effectData = {
@@ -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 });
@@ -10,12 +10,20 @@ 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({
21
29
  head: Object.freeze({ kind: 's3.head', capability: 's3.head', result: 's3-head-result' }),
@@ -586,7 +594,10 @@ function createPackageRuntime(input) {
586
594
  operation,
587
595
  capability: declared.capability,
588
596
  result: declared.result,
589
- payload: clonePackageEffectPayload(payload)
597
+ payload: clonePackageEffectPayload(payload, {
598
+ maxBytes: declared.kind === 'crypto.digestText' || declared.kind === 's3.putText'
599
+ ? TEXT_MAX_PAYLOAD_BYTES : DEFAULT_MAX_PAYLOAD_BYTES
600
+ })
590
601
  }, projector);
591
602
  }
592
603
  });
@@ -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);
@@ -0,0 +1,100 @@
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 = 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 = () => controller.signal.removeEventListener('abort', callback);
60
+ listeners.push(remove);
61
+ controller.signal.addEventListener('abort', callback, { once: true });
62
+ return remove;
63
+ },
64
+ remainingMs() { budget.check(); return deadline === undefined ? Infinity : Math.max(0, deadline - last); },
65
+ race(value) {
66
+ // Always observe the pending work, including an already-expired request.
67
+ const pending = Promise.resolve(value);
68
+ return new Promise((resolve, reject) => {
69
+ const abort = () => { cleanup(); reject(controller.signal.reason); };
70
+ controller.signal.addEventListener('abort', abort, { once: true });
71
+ const cleanup = () => controller.signal.removeEventListener('abort', abort);
72
+ pending.then(result => {
73
+ cleanup();
74
+ try { budget.check(); resolve(result); } catch (error) { reject(error); }
75
+ }, error => { cleanup(); reject(error); });
76
+ try { budget.check(); } catch (error) { cleanup(); reject(error); }
77
+ });
78
+ },
79
+ close() {
80
+ if (closed) return;
81
+ closed = true;
82
+ if (timer !== undefined) clock.clearTimeout(timer);
83
+ for (const remove of listeners) remove();
84
+ listeners.length = 0;
85
+ }
86
+ });
87
+ budgets.add(budget);
88
+ if (deadline !== undefined && !controller.signal.aborted) {
89
+ const schedule = () => {
90
+ try {
91
+ budget.check();
92
+ timer = clock.setTimeout(schedule, budget.remainingMs());
93
+ } catch (_) { /* check has already revoked execution authority */ }
94
+ };
95
+ timer = clock.setTimeout(schedule, duration);
96
+ }
97
+ return budget;
98
+ }
99
+
100
+ module.exports = { createRequestBudget, normalizeRequestDuration };
@@ -1,9 +1,11 @@
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,
@@ -159,7 +161,7 @@ function redactedError(frame, error) {
159
161
 
160
162
  function containUnexpected(error, frame) {
161
163
  const safe = redactedError(frame, error);
162
- return safe instanceof PulseUnhandledError ? safe : new PulseUnhandledError(safe);
164
+ return isApplicationError(error) || safe instanceof PulseUnhandledError ? safe : new PulseUnhandledError(safe);
163
165
  }
164
166
 
165
167
  function contractError(code, message) {
@@ -192,6 +194,7 @@ async function dispatchRouter(router, frame, startIndex, activeError) {
192
194
  let error = activeError;
193
195
 
194
196
  while (index < entries.length) {
197
+ frame.signal?.throwIfAborted();
195
198
  const current = entries[index];
196
199
 
197
200
  if (error !== NO_ERROR) {
@@ -300,6 +303,7 @@ async function runNormalHandler(_router, frame, index, handler, routeContext) {
300
303
  } catch (error) {
301
304
  if (handlerError === undefined) handlerError = error;
302
305
  }
306
+ frame.signal?.throwIfAborted();
303
307
  if (handlerError !== undefined) {
304
308
  return Object.freeze({ kind: 'continue', index: index + 1, error: containUnexpected(handlerError, frame), frame });
305
309
  }
@@ -353,6 +357,7 @@ async function runErrorHandler(_router, frame, index, activeError, handler) {
353
357
  } catch (error) {
354
358
  if (handlerError === undefined) handlerError = error;
355
359
  }
360
+ frame.signal?.throwIfAborted();
356
361
  if (handlerError !== undefined) {
357
362
  return Object.freeze({ kind: 'continue', index: index + 1, error: containUnexpected(handlerError, frame), frame });
358
363
  }
@@ -409,52 +414,60 @@ async function executeRouter(router, request, options = {}) {
409
414
  // Logging evidence is best effort.
410
415
  }
411
416
  }
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
- };
417
+ const ownsBudget = options.requestBudget === undefined;
418
+ const budget = createRequestBudget({ ...options, requestSignal: request.signal });
419
+ options = { ...options, requestBudget: budget, signal: budget.signal, deadlineMonotonicMs: budget.deadlineMonotonicMs ?? options.deadlineMonotonicMs, kvClock: budget.deadlineMonotonicMs === undefined ? options.kvClock : budget.clock };
420
+ const executionSignal = budget.signal;
421
+ let effectExecution;
456
422
  try {
457
- const result = await dispatchRouter(router, frame, 0, NO_ERROR);
423
+ budget.check();
424
+ effectExecution = createJavascriptEffectExecution({
425
+ effectAdapter: options.effectAdapter,
426
+ capabilities: options.capabilities,
427
+ application: options.application,
428
+ request,
429
+ executionKind: 'request',
430
+ signal: executionSignal,
431
+ requestBudget: budget,
432
+ maxEffects: options.maxEffects,
433
+ kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
434
+ maxBindingNameBytes: options.maxBindingNameBytes,
435
+ maxBindingValueBytes: options.maxBindingValueBytes,
436
+ maxKvNamespaceBytes: options.maxKvNamespaceBytes,
437
+ maxKvKeyBytes: options.maxKvKeyBytes,
438
+ maxKvValueBytes: options.maxKvValueBytes,
439
+ maxKvValueDepth: options.maxKvValueDepth,
440
+ maxKvValueEntries: options.maxKvValueEntries,
441
+ schemaCodecs: options.schemaCodecs,
442
+ strict: options.strict,
443
+ target: options.target,
444
+ provider: options.provider,
445
+ redactionValues: options.redactionValues,
446
+ onEffectObservation: options.onEffectObservation
447
+ });
448
+ const req = createRequestView(request, options.requestHeaders, effectExecution, {
449
+ ...options,
450
+ signal: executionSignal,
451
+ redactJsonTraceValue(value) { return effectExecution.redactValue(value); }
452
+ });
453
+ const executionOptions = Object.freeze({
454
+ ...options,
455
+ signal: executionSignal,
456
+ redactJsonTraceValue(value) { return effectExecution.redactValue(value); }
457
+ });
458
+ const frame = {
459
+ request,
460
+ req,
461
+ relativePath: normalizeRoutePath(req.path),
462
+ params: Object.freeze({}),
463
+ state: new Map(),
464
+ effectExecution,
465
+ signal: executionSignal,
466
+ executionOptions
467
+ };
468
+ budget.check();
469
+ const result = await budget.race(dispatchRouter(router, frame, 0, NO_ERROR));
470
+ executionSignal?.throwIfAborted();
458
471
  if (result.kind === 'response') return result.response;
459
472
  if (result.error !== NO_ERROR) {
460
473
  return new Response('Internal Server Error', {
@@ -468,9 +481,10 @@ async function executeRouter(router, request, options = {}) {
468
481
  });
469
482
  } finally {
470
483
  try {
471
- await effectExecution.close();
484
+ await effectExecution?.close();
472
485
  } finally {
473
- if (typeof options.onEffectSummary === 'function') options.onEffectSummary(effectExecution.summary());
486
+ if (ownsBudget) budget.close();
487
+ if (effectExecution && typeof options.onEffectSummary === 'function') options.onEffectSummary(effectExecution.summary());
474
488
  }
475
489
  }
476
490
  }
@@ -187,6 +187,7 @@ function emitSchemaTrace(options, event, value) {
187
187
 
188
188
  function decodeSchemaText(schemaId, text, options = {}, context = {}) {
189
189
  const source = String(context.source || 'json');
190
+ const applicationText = source === 'application-text';
190
191
  const id = requireSchemaId(schemaId, options, source);
191
192
  if (id === undefined) {
192
193
  try {
@@ -197,24 +198,28 @@ function decodeSchemaText(schemaId, text, options = {}, context = {}) {
197
198
  }
198
199
  let contentType = normalizeContentType(headerValue(context.headers, 'content-type'));
199
200
  const event = {
200
- boundary: source === 'request' ? 'request' : 'fetch-response',
201
+ boundary: applicationText ? source : source === 'request' ? 'request' : 'fetch-response',
201
202
  schemaId: id,
202
203
  responseCaseId: null,
203
204
  operationId: String(context.operationId || `${source}:${id}`),
204
205
  status: context.status ?? null,
205
206
  headers: context.headers || [],
206
- bodyOwnership: source === 'request' ? 'request-snapshot' : 'fetched-response-snapshot',
207
+ bodyOwnership: applicationText ? 'application-owned' : source === 'request' ? 'request-snapshot' : 'fetched-response-snapshot',
207
208
  effectId: context.effectId || null,
208
209
  groupId: context.groupId || null
209
210
  };
210
211
  try {
211
- contentType = assertSchemaContentType(options, context.headers, id, source);
212
+ if (applicationText && typeof text !== 'string') {
213
+ throw schemaRuntimeError('PULSE_SCHEMA_DECODE', 'Application JSON text must be a string.',
214
+ { schemaId: id, source, expected: 'string', actualKind: typeof text });
215
+ }
216
+ if (!applicationText) contentType = assertSchemaContentType(options, context.headers, id, source);
212
217
  assertSchemaBodySize(options, text, id, source);
213
218
  const codecs = requireSchemaCodecs(options, id, source);
214
219
  const value = codecs.decodeJsonText(id, String(text), source);
215
220
  emitSchemaTrace(options, {
216
221
  ...event,
217
- kind: source === 'request' ? 'json.decode.request' : 'json.decode.fetch',
222
+ kind: applicationText ? 'json.decode.text' : source === 'request' ? 'json.decode.request' : 'json.decode.fetch',
218
223
  contentType: contentType || null
219
224
  }, value);
220
225
  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 });