@pulse-compute/runtime 1.0.0-beta.5 → 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.
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.5/packages/runtime/)
10
+ > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.6/packages/runtime/)
11
11
  >
12
- > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.5` package policy.
12
+ > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.6` 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,17 +1,17 @@
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.5/reference/handler-authoring/). For provider and target
4
+ JavaScript](https://pulsecompute.io/v1.0.0-beta.6/reference/handler-authoring/). For provider and target
5
5
  differences, use the [compatibility
6
- matrix](https://pulsecompute.io/v1.0.0-beta.5/reference/compatibility-matrix/). For CLI and runtime failures,
6
+ matrix](https://pulsecompute.io/v1.0.0-beta.6/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.5/reference/diagnostics/).
8
+ reference](https://pulsecompute.io/v1.0.0-beta.6/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
 
12
12
  `@pulse-compute/runtime` is the low-level portable application surface and
13
13
  `@pulse-compute/pulse` is the conventional project surface. Both belong to the
14
- 14-package public release catalog. Native and JavaScript execution remain
14
+ 19-package public release catalog. Native and JavaScript execution remain
15
15
  explicitly selected targets over the same canonical runtime contract.
16
16
 
17
17
  ## Context at a glance
@@ -52,7 +52,7 @@ Every managed handler is async-shaped:
52
52
  type Handler = (ctx: PulseContext) => Promise<PulseResult | PulseFetchResponse>
53
53
  ```
54
54
 
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.
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.6/reference/handler-authoring/) defines the static language subset; the [compatibility matrix](https://pulsecompute.io/v1.0.0-beta.6/reference/compatibility-matrix/) owns the tested four-mode claims.
56
56
 
57
57
  ## Static `Router`
58
58
 
@@ -140,9 +140,9 @@ eligibility. Fastly targets fail closed because no event ingress/emit adapter is
140
140
  claimed. There is no public event injection command, no event-aware development
141
141
  listener, no HTTP/GRIP translation, and no automatic target fallback.
142
142
 
143
- The [static events guide](https://pulsecompute.io/v1.0.0-beta.5/guides/events/) owns the complete frame,
143
+ The [static events guide](https://pulsecompute.io/v1.0.0-beta.6/guides/events/) owns the complete frame,
144
144
  queue, target-eligibility, diagnostic, and Native-extension contract. The
145
- source-bound [event example](https://pulsecompute.io/v1.0.0-beta.5/examples/11-events/) runs the same mixed project
145
+ source-bound [event example](https://pulsecompute.io/v1.0.0-beta.6/examples/11-events/) runs the same mixed project
146
146
  on Node JavaScript and Node Native.
147
147
 
148
148
  ## `ctx.req`
@@ -154,6 +154,8 @@ interface PulseRequest {
154
154
  readonly path: string
155
155
  readonly headers: readonly [string, string][]
156
156
  header(name: string): string | undefined
157
+ body(): PulseIncomingBody
158
+ readTextChunk(): PulseEffect<{ readonly done: boolean; readonly text: string }>
157
159
  text(): PulseEffect<string>
158
160
  json<T = unknown>(schemaId?: string): PulseEffect<T>
159
161
  }
@@ -161,6 +163,15 @@ interface PulseRequest {
161
163
 
162
164
  Structured request bodies are bounded runtime-owned snapshots. Repeated `text()` and `json()` reads are memoized immutable transforms.
163
165
 
166
+ `body()` is an opaque incoming handle under the Node `bodyForwarding` opt-in;
167
+ it grants no reader or binary inspection. `readTextChunk()` belongs only to the
168
+ experimental Node `bodyTransform` opt-in, with generated output and a finite
169
+ deadline. It excludes forwarding and structured body reads. Both Fastly targets
170
+ reject these capabilities. The [bodies guide](https://pulsecompute.io/v1.0.0-beta.6/concepts/bodies/) owns the
171
+ byte/read/write limits and separates installed output qualification from pending
172
+ installed transform qualification. The production `/server` launcher remains
173
+ qualified for finite HTTP, as described in [Node deployment](https://pulsecompute.io/v1.0.0-beta.6/guides/deploying-node/).
174
+
164
175
  ### Request metadata and headers
165
176
 
166
177
  ```ts
@@ -392,7 +403,7 @@ its matching UTC string. A failed sample has `status: 'failed'` and reason
392
403
  `'unavailable'` or `'invalid-clock'`. Direct awaited calls and keyed parallel
393
404
  members are supported on Node/Fastly Native and JavaScript. Wall time can regress
394
405
  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).
406
+ [complete result contract](https://pulsecompute.io/v1.0.0-beta.6/packages/runtime/#wall-time).
396
407
 
397
408
  ## Config and secrets
398
409
 
@@ -465,7 +476,7 @@ projects the value, then returns detached JSON text bounded by `schemas.maxBytes
465
476
  in UTF-8 bytes. It does not create a response or dispatch an effect. Invalid
466
477
  values and oversized text fail before subsequent writes. Declaration order and
467
478
  array order are preserved; cross-target parity is semantic, not a universal
468
- canonical-byte format. See [application-owned encoding](https://pulsecompute.io/v1.0.0-beta.5/guides/json-schemas/#encode-application-owned-text).
479
+ canonical-byte format. See [application-owned encoding](https://pulsecompute.io/v1.0.0-beta.6/guides/json-schemas/#encode-application-owned-text).
469
480
 
470
481
  ## `ctx.decodeJson`
471
482
 
@@ -478,7 +489,7 @@ be a string within `schemas.maxBytes` UTF-8 bytes. Decoding returns a detached,
478
489
  deeply immutable value with declared fields only. It performs no effect and
479
490
  does not apply HTTP content-type policy. Keep the original text for hashing,
480
491
  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).
492
+ storage acceptance. See [application text decoding](https://pulsecompute.io/v1.0.0-beta.6/guides/json-schemas/#decode-application-owned-text).
482
493
 
483
494
  ## Explicit JSON schemas
484
495
 
@@ -521,12 +532,12 @@ forms lower into canonical package operations; JavaScript targets execute the
521
532
  real package implementation.
522
533
 
523
534
  Older `/pulsewasm` imports are compatibility-only and are isolated in the
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.
535
+ [migration guide](https://pulsecompute.io/v1.0.0-beta.6/guides/compatibility-imports/). See the
536
+ [GRIP package guide](https://pulsecompute.io/v1.0.0-beta.6/packages/grip/) for the complete current surface.
526
537
 
527
538
  ## Entities API
528
539
 
529
- `@pulse-compute/entities` is part of the synchronized `1.0.0-beta.5` package
540
+ `@pulse-compute/entities` is part of the synchronized `1.0.0-beta.6` package
530
541
  set. The application surface has two runtime values:
531
542
 
532
543
  ```ts
@@ -588,9 +599,9 @@ Registrations and the terminal binding must use the supported static form. The
588
599
  first-party JSON-RPC adapter accepts bounded JSON-RPC 2.0 request objects and
589
600
  named params, validates declared schemas, uses stable error framing, and
590
601
  acknowledges notifications with HTTP `204`. See the [package
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/).
602
+ guide](https://pulsecompute.io/v1.0.0-beta.6/packages/entities/), [entity/adapter
603
+ model](https://pulsecompute.io/v1.0.0-beta.6/concepts/entities-and-adapters/), and [executable
604
+ example](https://pulsecompute.io/v1.0.0-beta.6/examples/10-entities-tools/).
594
605
 
595
606
  ## Project workflow
596
607
 
@@ -607,5 +618,5 @@ selected profile, handler entry, schema declarations, provider bindings, and
607
618
  output directory through `.pulse/config.ts`. `pulse inspect` is optional
608
619
  observability, and `pulse compile` is the advanced provider-neutral Native
609
620
  artifact command; neither is required before `pulse build`. See the [project
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/).
621
+ lifecycle guide](https://pulsecompute.io/v1.0.0-beta.6/guides/project-lifecycle/) and [CLI
622
+ reference](https://pulsecompute.io/v1.0.0-beta.6/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.5/reference/handler-authoring/); target claims live in the [compatibility matrix](https://pulsecompute.io/v1.0.0-beta.5/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.6/reference/handler-authoring/); target claims live in the [compatibility matrix](https://pulsecompute.io/v1.0.0-beta.6/reference/compatibility-matrix/).
4
4
 
5
5
  <!-- pulse-doc-source: examples/09-router-lowering/src/index.ts -->
6
6
  ```ts
@@ -105,6 +105,43 @@ acceptance only; they do not implement Catalog persistence or authorization.
105
105
  node wasm/scripts/run-wasm-tests.cjs --task catalog-router-parity --no-report
106
106
  ```
107
107
 
108
+ ## Selected groups
109
+
110
+ Mount a group once after earlier selectors have set request-local state:
111
+
112
+ ```ts
113
+ app.mount('/api', collection, { state: 'read-family', equals: 'collection' })
114
+ ```
115
+
116
+ The optional third argument is an inline object containing exactly `state` and
117
+ `equals`, both string literals. At this mount's ordered position, the request
118
+ path must match and `ctx.state.get('read-family') === 'collection'` must hold.
119
+ An absent or unequal value skips every child owner and effect directly to the
120
+ parent continuation. Selection is not authorization; keep authorization in its
121
+ existing owner.
122
+
123
+ A match enters the child once. Child `next()` still advances to the next sibling,
124
+ and exhaustion joins the parent continuation once. Terminal responses and ordered
125
+ local/parent error handling keep their existing semantics. A suspended effect
126
+ resumes inside the child; changing selection state there does not reevaluate the
127
+ mount. Method, path and parameter scoping are unchanged. An empty selected child
128
+ continues immediately.
129
+
130
+ This form is supported by live JavaScript and canonical Native compilation.
131
+ Eligibility does not accept callbacks, calls, getters, spreads, computed keys,
132
+ descriptor references or dynamic values. Unsupported source receives
133
+ `PULSEWASM_UNSUPPORTED_MOUNT_ELIGIBILITY`; historical table/harness emitters also
134
+ reject selected mounts rather than ignoring them. Two-argument mounts are
135
+ unchanged. The group remains one placement with one existing parent continuation.
136
+
137
+ The `router-selected-groups` task checks exclusion, suspension after a state
138
+ change, terminal/error paths, nested parameters and one common tail on live
139
+ JavaScript and compiled Node/Fastly Native (Fastly uses its local ABI mock host).
140
+
141
+ ```sh
142
+ node wasm/scripts/run-wasm-tests.cjs --task router-selected-groups --no-report
143
+ ```
144
+
108
145
  ## `next()` is a terminal transfer
109
146
 
110
147
  `next()` is not an onion-style callback. It is a compiler-visible control transfer:
@@ -157,6 +194,11 @@ identical messages, stacks, causes or detail fields across providers.
157
194
  | 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
195
  | JWT claims | `PULSE_JWT_CLOCK_INVALID`, `PULSE_JWT_CLAIMS_INVALID`, `PULSE_JWT_CLAIMS_SCHEMA_INVALID` |
159
196
 
197
+ These admitted `error.code` values remain stable through redaction, including
198
+ when a secret, KV key or array index overlaps their spelling. Messages, stacks,
199
+ causes, details and arbitrary provider error codes remain subject to redaction.
200
+ This preserves the existing recovery catalog; it does not admit new failures.
201
+
160
202
  Recovery moves forward in registration order, including through mounted
161
203
  routers. A failed handler never resumes. An error handler can return a response,
162
204
  forward with `return next(error)`, or clear the error lane with `return next()`.
@@ -1,6 +1,6 @@
1
1
  # Beta scope
2
2
 
3
- Pulse `1.0.0-beta.5` is a Beta of one provider-neutral application contract
3
+ Pulse `1.0.0-beta.6` 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.
@@ -19,7 +19,7 @@ targets.
19
19
  wildcards, and acyclic mounts.
20
20
  - Basic branching and structured object, array, and scalar manipulation.
21
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).
22
+ Native and JavaScript; see [bounded application values](https://pulsecompute.io/v1.0.0-beta.6/concepts/compilation-and-lowering/#bounded-application-values).
23
23
  - Request method, URL, path, headers, text, and JSON access.
24
24
  - Bounded, memoized structured body decoding.
25
25
  - Explicit TypeScript JSON schema declarations and literal schema IDs.
@@ -77,8 +77,10 @@ JavaScript-selected project into a Native one.
77
77
 
78
78
  ## Deliberately unsupported
79
79
 
80
- - Effectful or unbounded loops, arbitrary callback transformations, and Native
81
- pure-helper calls. Pure loop bodies cannot transfer from the handler.
80
+ - Unbounded loops, arbitrary callback transformations and Native helper shapes
81
+ outside the supported bounded subset. Effectful loops are limited to the
82
+ experimental finite Node output/transform forms in the [bodies guide](https://pulsecompute.io/v1.0.0-beta.6/concepts/bodies/);
83
+ other loop bodies must stay within the admitted pure forms.
82
84
 
83
85
  - Automatic fallback from Native lowering to JavaScript execution.
84
86
  - Declaring general target availability without satisfying every declared
@@ -93,7 +95,9 @@ JavaScript-selected project into a Native one.
93
95
  - Automatic discovery of arbitrary TypeScript types.
94
96
  - Dynamic schema IDs.
95
97
  - Arbitrary binary body inspection or mutation.
96
- - Userland chunk iteration, transform streams, or manual backpressure.
98
+ - General userland chunk iteration, transform streams, or manual backpressure.
99
+ Experimental finite Node UTF-8 output/transforms are explicit bounded opt-ins,
100
+ not general stream support or production-launcher qualification.
97
101
  - Background tasks and work that outlives the request.
98
102
  - Raw TCP or UDP sockets.
99
103
  - Dynamic GRIP channel, framing-option, or message shapes under Native
@@ -111,13 +115,13 @@ JavaScript-selected project into a Native one.
111
115
  ## Compatibility authority
112
116
 
113
117
  The single source-form, provider-binding, artifact, and deployment-boundary
114
- table is [Provider and target compatibility](https://pulsecompute.io/v1.0.0-beta.5/reference/compatibility-matrix/).
118
+ table is [Provider and target compatibility](https://pulsecompute.io/v1.0.0-beta.6/reference/compatibility-matrix/).
115
119
  It uses one public target order—Node JavaScript, Fastly JavaScript, Node Native,
116
120
  and Fastly Native—and links every row to a focused proof or canonical contract.
117
121
 
118
122
  The exact portable language subset and the JavaScript-only Native eligibility
119
123
  boundaries are defined in
120
- [Managed handler TypeScript and JavaScript](https://pulsecompute.io/v1.0.0-beta.5/reference/handler-authoring/).
124
+ [Managed handler TypeScript and JavaScript](https://pulsecompute.io/v1.0.0-beta.6/reference/handler-authoring/).
121
125
 
122
126
  ## Body model
123
127
 
@@ -141,7 +145,7 @@ pass it through → opaque handle
141
145
  - Package names, release artifacts, and published versions are immutable once
142
146
  released.
143
147
 
144
- The intended npm dist-tag for the Beta is `beta`. It becomes
145
- active only through the atomic documentation-release transaction and an
146
- explicitly authorized publication. The workflow never assigns `latest`
147
- implicitly.
148
+ The release manifest explicitly selects the npm `latest` dist-tag while package
149
+ versions retain the Beta prerelease suffix. The npm tag and Git branch are
150
+ separate names. Publication remains a distinct human-authorized action; this
151
+ support cleanup does not change that policy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulse-compute/runtime",
3
- "version": "1.0.0-beta.5",
3
+ "version": "1.0.0-beta.6",
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.5/packages/runtime/",
44
+ "homepage": "https://pulsecompute.io/v1.0.0-beta.6/packages/runtime/",
45
45
  "bugs": {
46
46
  "url": "https://github.com/pulse-compute/pulse/issues"
47
47
  }
package/src/host.d.ts CHANGED
@@ -177,6 +177,8 @@ export interface PulseJavascriptEffectSummary {
177
177
 
178
178
  /** Maintainer bridge used by the managed Router lifecycle and focused adapter tests. */
179
179
  export interface PulseJavascriptEffectExecution {
180
+ /** Host/runtime admission fence; rejects queued provider dispatch after an ownership conflict. */
181
+ invalidateAdmission(error: unknown): void;
180
182
  /** Execution-owned local projection. It participates in lifecycle containment but is not parallel-eligible. */
181
183
  local<T = unknown>(
182
184
  kind: string,
@@ -222,7 +224,23 @@ export declare function createRequestBudget(options?: {
222
224
  requestSignal?: AbortSignal;
223
225
  }): PulseRequestBudget;
224
226
 
227
+ export interface PulseGeneratedOutputExecution {
228
+ readonly started: boolean;
229
+ readonly finished: boolean;
230
+ assertEffect(): void;
231
+ dispatch(effect: Readonly<Record<string, unknown>>): Promise<unknown>;
232
+ bindInput(input: PulseIncomingBodyOwnership | undefined): void;
233
+ close(factory?: (options: import('./index').PulseResponseOptions) => unknown): any;
234
+ validateResult(result: unknown): void;
235
+ finish(): Promise<void>;
236
+ cancel(reason?: unknown): void;
237
+ dispose(): void;
238
+ }
225
239
  export interface PulseRuntimeExecutionOptions {
240
+ /** Provider-owned output authority; unavailable to application code. */
241
+ readonly outputExecution?: PulseGeneratedOutputExecution;
242
+ /** Provider-owned incoming stream controller; never an application capability. */
243
+ readonly incomingBody?: PulseIncomingBodyOwnership;
226
244
  /** Provider-owned total managed request budget, 1..30000 ms. Omitted preserves existing behavior. */
227
245
  readonly maxDurationMs?: number;
228
246
  /** Shared host budget; nested adapters must not restart or close an inherited budget. */
@@ -454,3 +472,24 @@ export declare function isApplicationError(error: unknown): boolean;
454
472
  export declare const WALL_TIME_MAX_MS: 253402300799999;
455
473
  export declare function readWallTime(clock?: (() => number) | null): PulseTimeResult;
456
474
  export declare function normalizeTimeResult(value: unknown): PulseTimeResult;
475
+
476
+ /** Host-only lifecycle authority for one incoming request body. */
477
+ export interface PulseIncomingBodyOwnership {
478
+ marker(): import('./index').PulseIncomingBody;
479
+ bindInvalidation(callback: (error: unknown) => void): void;
480
+ structured(): void;
481
+ readTextChunk(budget: PulseRequestBudget): Promise<{ readonly done: boolean; readonly text: string }>;
482
+ claim(marker: unknown, init: unknown): void;
483
+ forward(url: string, init: unknown, execution: PulseJavascriptEffectHostExecution): Promise<Response>;
484
+ close(): Promise<void>;
485
+ readonly failure: unknown;
486
+ readonly responseSignal?: AbortSignal;
487
+ }
488
+ export declare function createIncomingBodyOwnership(transport: {
489
+ readonly responseSignal?: AbortSignal;
490
+ readTextChunk?(budget: PulseRequestBudget): Promise<{ readonly done: boolean; readonly text: string }>;
491
+ validate(init: unknown): void;
492
+ forward(url: string, init: unknown, execution: PulseJavascriptEffectHostExecution): Promise<Response>;
493
+ cancel(reason?: unknown): unknown;
494
+ }): PulseIncomingBodyOwnership;
495
+ export declare function forwardIncomingBody(marker: unknown, url: string, init: unknown, execution: PulseJavascriptEffectHostExecution): Promise<Response>;
package/src/host.js CHANGED
@@ -78,6 +78,8 @@ async function executeApplication(application, request, options = {}) {
78
78
  }
79
79
 
80
80
  module.exports = Object.freeze({
81
+ createIncomingBodyOwnership: require('./internal/incoming-body.js').createIncomingBodyOwnership,
82
+ forwardIncomingBody: require('./internal/incoming-body.js').forwardIncomingBody,
81
83
  ...require('./internal/time.js'),
82
84
  ...require('./internal/request-budget.js'),
83
85
  isApplicationError: require('./internal/errors.js').isApplicationError,
package/src/index.d.ts CHANGED
@@ -2,6 +2,10 @@ export type HeaderPair = readonly [name: string, value: string];
2
2
 
3
3
  declare const pulseEffectBrand: unique symbol;
4
4
  declare const pulseParallelEffectBrand: unique symbol;
5
+ declare const incomingBodyBrand: unique symbol;
6
+
7
+ /** Single-use opaque marker, admitted only inline as a configured Node fetch body. */
8
+ export interface PulseIncomingBody { readonly [incomingBodyBrand]: true; }
5
9
 
6
10
  export interface PulseEffect<T> extends Promise<T> {
7
11
  readonly [pulseEffectBrand]: T;
@@ -23,6 +27,9 @@ export interface PulseRequest {
23
27
  readonly path: string;
24
28
  readonly headers: readonly HeaderPair[];
25
29
  header(name: string): string | undefined;
30
+ body(): PulseIncomingBody;
31
+ /** Experimental finite UTF-8 transform input; Node bodyTransform opt-in required. */
32
+ readTextChunk(): PulseEffect<{ readonly done: boolean; readonly text: string }>;
26
33
  text(): PulseEffect<string>;
27
34
  json<T = unknown>(schemaId?: string): PulseEffect<T>;
28
35
  }
@@ -35,7 +42,7 @@ export interface PulseFetchInitBase {
35
42
 
36
43
  export type PulseFetchInit = PulseFetchInitBase & (
37
44
  | { readonly body?: never; readonly json?: never; readonly schema?: never }
38
- | { readonly body: string; readonly json?: never; readonly schema?: never }
45
+ | { readonly body: string | PulseIncomingBody; readonly json?: never; readonly schema?: never }
39
46
  | { readonly body?: never; readonly json: unknown; readonly schema?: string }
40
47
  );
41
48
 
@@ -166,6 +173,12 @@ export interface PulseExecutionContext {
166
173
 
167
174
  /** HTTP request execution context. */
168
175
  export interface PulseContext extends PulseExecutionContext {
176
+ /** Experimental Node output: opt-in, finite, request-owned UTF-8 writes. */
177
+ readonly output: {
178
+ start(options?: PulseResponseOptions): PulseEffect<void>;
179
+ write(text: string): PulseEffect<void>;
180
+ close(): PulseResult;
181
+ };
169
182
  readonly req: PulseRequest;
170
183
  json(value: unknown, descriptor?: PulseJsonResponseOptions | string): PulseResult;
171
184
  text(value: string, options?: PulseResponseOptions): PulseResult;
@@ -218,7 +231,8 @@ export declare class Router {
218
231
  put(path: string, handler: RouteHandler): this;
219
232
  patch(path: string, handler: RouteHandler): this;
220
233
  delete(path: string, handler: RouteHandler): this;
221
- mount(path: string, router: Router): this;
234
+ /** Enter the child once only when request state strictly equals the literal string; a miss skips the entire child. */
235
+ mount(path: string, router: Router, eligibility?: { readonly state: string; readonly equals: string }): this;
222
236
  error(handler: RouterErrorHandler): this;
223
237
  }
224
238
 
@@ -200,7 +200,10 @@ function createStructuredBodyReader(owner, options = {}) {
200
200
 
201
201
  function bytes() {
202
202
  assertInspectable();
203
- if (!bytesPromise) bytesPromise = readBodyBytes(owner, maxBytes, label, signal);
203
+ if (!bytesPromise) {
204
+ bytesPromise = readBodyBytes(owner, maxBytes, label, signal);
205
+ if (options.onReadSettled) bytesPromise = bytesPromise.finally(options.onReadSettled);
206
+ }
204
207
  return bytesPromise;
205
208
  }
206
209
 
@@ -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); },
@@ -169,13 +187,24 @@ function createContext(frame) {
169
187
  state,
170
188
  log,
171
189
  fetch(url, init) {
172
- fetchSequence += 1;
190
+ const fetchSequence = (fetchSequences.get(effects) || 0) + 1;
191
+ fetchSequences.set(effects, fetchSequence);
173
192
  const effectId = `fetch-${fetchSequence}`;
174
- const fetchRequest = normalizeFetchRequest(url, init, {
175
- ...options,
176
- operationId: `fetch-request:${effectId}`,
177
- effectId
178
- });
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
+ }
179
208
  const fetchEffect = effects.dispatch({
180
209
  id: effectId,
181
210
  kind: 'fetch',
@@ -266,6 +295,26 @@ function createContext(frame) {
266
295
  if (eventContext) {
267
296
  ctx.event = frame.event;
268
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
+ });
269
318
  ctx.json = (value, descriptor) => createJsonResult(value, descriptor, options);
270
319
  ctx.text = (value, responseOptions) => createTextResult(value, responseOptions);
271
320
  ctx.response = (input) => createResponseResult(input);
@@ -15,6 +15,7 @@ const { fetchRequestInitForHost } = require('./fetch.js');
15
15
  const { createRedactionState } = require('./redaction.js');
16
16
  const { validateSchemaValue } = require('./schema.js');
17
17
  const { EVENT_EMIT_CODES } = require('./event-emission.js');
18
+ const { cancelResponseBody, retainFetchResponseForBudget, releaseFetchResponseCancellation } = require('./response.js');
18
19
 
19
20
  const JAVASCRIPT_EFFECT_PROTOCOL_VERSION = 'pulse.javascript-effect.v1';
20
21
  const JAVASCRIPT_EFFECT_ADAPTER_VERSION = 'pulse.javascript-effect-adapter.v1';
@@ -510,6 +511,7 @@ function createJavascriptEffectExecution(options = {}) {
510
511
  const counters = new Map();
511
512
  const dispatchedIds = new Set();
512
513
  const pendingEffects = new Map();
514
+ const ownedResponses = new Set();
513
515
  const claimedParallelRootIds = new Set();
514
516
  const observations = [];
515
517
  const resolutionOrder = [];
@@ -571,6 +573,7 @@ function createJavascriptEffectExecution(options = {}) {
571
573
  limitMessage
572
574
  );
573
575
  }
576
+ options.outputExecution?.assertEffect();
574
577
  const normalizedInput = normalizeEffectDescriptor(input, limits);
575
578
  const kind = String(normalizedInput.kind || 'effect');
576
579
  const descriptor = Object.freeze({
@@ -605,14 +608,21 @@ function createJavascriptEffectExecution(options = {}) {
605
608
  const raw = raceWithSignal(Promise.resolve().then(() => {
606
609
  options.requestBudget?.check();
607
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
+ }
608
616
  if (conditionalKv.isConditionalKv(kind)) return conditionalKv.executeConditionalKv(descriptor,
609
617
  adapter.prepareConditionalKv || ((_admitted, kvExecution) => () => adapter.dispatch(descriptor, kvExecution)),
610
618
  { ...operationExecution, onKvObservation: observe }, limits);
611
619
  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
- });
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
+ }
616
626
  }
617
627
  return value;
618
628
  });
@@ -739,7 +749,12 @@ function createJavascriptEffectExecution(options = {}) {
739
749
 
740
750
  parallel(record) {
741
751
  assertOpen();
742
- 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
+ }
743
758
  for (const entry of entries) claimedParallelRootIds.add(entry.data.rootId);
744
759
  parallelCount += 1;
745
760
  const id = `parallel-${parallelCount}`;
@@ -806,6 +821,11 @@ function createJavascriptEffectExecution(options = {}) {
806
821
  );
807
822
  return parallelEffect;
808
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
+ },
809
829
 
810
830
  async assertIdle() {
811
831
  if (pendingEffects.size === 0) return;
@@ -856,7 +876,7 @@ function createJavascriptEffectExecution(options = {}) {
856
876
  });
857
877
  },
858
878
 
859
- async close() {
879
+ async close(transferredResponse) {
860
880
  if (closed) return;
861
881
  closed = true;
862
882
  if (pendingEffects.size > 0 && !lifecycleController.signal.aborted) {
@@ -869,9 +889,21 @@ function createJavascriptEffectExecution(options = {}) {
869
889
  }
870
890
  if (pendingEffects.size > 0) await Promise.allSettled(Array.from(pendingEffects.keys()));
871
891
  if (removeSourceAbortListener) removeSourceAbortListener();
872
- if (adapter.dispose) {
873
- try { await adapter.dispose(externalExecution); }
874
- 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();
875
907
  }
876
908
  }
877
909
  };
@@ -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 };
@@ -26,6 +26,7 @@ const TRUSTED_PACKAGE_EFFECT_CATALOG = Object.freeze({
26
26
  }),
27
27
  '@pulse-compute/s3': Object.freeze({
28
28
  contractId: 'pulse.s3', providerKind: 's3', operations: Object.freeze({
29
+ getBody: Object.freeze({ kind: 's3.getBody', capability: 's3.getBody', result: 'opaque-response' }),
29
30
  head: Object.freeze({ kind: 's3.head', capability: 's3.head', result: 's3-head-result' }),
30
31
  getText: Object.freeze({ kind: 's3.getText', capability: 's3.getText', result: 's3-get-text-result' }),
31
32
  putText: Object.freeze({ kind: 's3.putText', capability: 's3.putText', result: 's3-put-text-result' })
@@ -57,6 +58,7 @@ const TRUSTED_PACKAGE_EFFECT_CATALOG = Object.freeze({
57
58
  contractId: 'pulse.jwt',
58
59
  providerKind: 'jwt',
59
60
  operations: Object.freeze({
61
+ sign: Object.freeze({ kind: 'jwt.sign', capability: 'jwt.sign', result: 'string' }),
60
62
  verify: Object.freeze({
61
63
  kind: 'jwt.verify',
62
64
  capability: 'jwt.verify',
@@ -595,6 +597,8 @@ function createPackageRuntime(input) {
595
597
  capability: declared.capability,
596
598
  result: declared.result,
597
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,
598
602
  maxBytes: declared.kind === 'crypto.digestText' || declared.kind === 's3.putText'
599
603
  ? TEXT_MAX_PAYLOAD_BYTES : DEFAULT_MAX_PAYLOAD_BYTES
600
604
  })
@@ -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>';
@@ -145,6 +145,10 @@ function createRedactionState(initialValues = []) {
145
145
  const rawMessage = ownDataValue(error, 'message');
146
146
  const rawName = ownDataValue(error, 'name');
147
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;
148
152
  const message = redactString(typeof rawMessage === 'string' ? rawMessage : 'Pulse runtime failure.');
149
153
  const causeDescriptor = Object.getOwnPropertyDescriptor(error, 'cause');
150
154
  const cause = causeDescriptor && Object.prototype.hasOwnProperty.call(causeDescriptor, 'value')
@@ -170,6 +174,10 @@ function createRedactionState(initialValues = []) {
170
174
  }
171
175
 
172
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
+ }
173
181
  for (const key of Reflect.ownKeys(error).slice(0, MAX_REDACTION_ENTRIES)) {
174
182
  if (typeof key !== 'string' || reserved.has(key)) continue;
175
183
  const descriptor = Object.getOwnPropertyDescriptor(error, key);
@@ -39,12 +39,12 @@ function createRequestBudget(options = {}) {
39
39
  const deadline = duration === undefined ? undefined : read() + duration;
40
40
  const expire = () => controller.abort(new PulseRuntimeContractError('PULSE_REQUEST_DEADLINE_EXCEEDED', 'Pulse request exceeded its total execution deadline.'));
41
41
  const signals = [...new Set([options.signal, options.requestSignal].filter(Boolean))];
42
- const listeners = signals.map(signal => {
42
+ const listeners = new Set(signals.map(signal => {
43
43
  const abort = () => controller.abort(signal.reason);
44
44
  if (signal.aborted) abort();
45
45
  else signal.addEventListener('abort', abort, { once: true });
46
46
  return () => signal.removeEventListener('abort', abort);
47
- });
47
+ }));
48
48
  const budget = Object.freeze({
49
49
  signal: controller.signal,
50
50
  deadlineMonotonicMs: deadline,
@@ -56,8 +56,11 @@ function createRequestBudget(options = {}) {
56
56
  },
57
57
  onAbort(callback) {
58
58
  if (controller.signal.aborted || closed) { callback(); return () => {}; }
59
- const remove = () => controller.signal.removeEventListener('abort', callback);
60
- listeners.push(remove);
59
+ const remove = () => {
60
+ controller.signal.removeEventListener('abort', callback);
61
+ listeners.delete(remove);
62
+ };
63
+ listeners.add(remove);
61
64
  controller.signal.addEventListener('abort', callback, { once: true });
62
65
  return remove;
63
66
  },
@@ -81,7 +84,7 @@ function createRequestBudget(options = {}) {
81
84
  closed = true;
82
85
  if (timer !== undefined) clock.clearTimeout(timer);
83
86
  for (const remove of listeners) remove();
84
- listeners.length = 0;
87
+ listeners.clear();
85
88
  }
86
89
  });
87
90
  budgets.add(budget);
@@ -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,
@@ -9,6 +9,7 @@ const { isApplicationError } = require('./errors.js');
9
9
  const {
10
10
  PulseRuntimeContractError,
11
11
  PulseUnhandledError,
12
+ cancelResponseBody,
12
13
  fetchResponseToResponse,
13
14
  isPulseFetchResponse,
14
15
  isPulseResult,
@@ -33,6 +34,17 @@ function requireRouter(value) {
33
34
  return value;
34
35
  }
35
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
+
36
48
  function mergeParams(left, right) {
37
49
  return Object.freeze({ ...(left || {}), ...(right || {}) });
38
50
  }
@@ -113,10 +125,11 @@ class Router {
113
125
  return this;
114
126
  }
115
127
 
116
- mount(path, router) {
128
+ mount(path, router, eligibility) {
117
129
  ROUTER_STATE.get(this).entries.push(entry('mount', {
118
130
  path: compileRoutePath(path, { scoped: true, allowWildcard: true }),
119
- router: requireRouter(router)
131
+ router: requireRouter(router),
132
+ eligibility: mountEligibility(eligibility)
120
133
  }));
121
134
  return this;
122
135
  }
@@ -173,9 +186,7 @@ async function normalizeHandlerResponse(value, requestMethod) {
173
186
  if (isPulseFetchResponse(value)) return fetchResponseToResponse(value, requestMethod);
174
187
  if (value instanceof Response) {
175
188
  if (String(requestMethod).toUpperCase() !== 'HEAD' && statusAllowsBody(value.status)) return value;
176
- if (value.body && !value.bodyUsed) {
177
- try { await value.body.cancel(); } catch (_) { /* ownership cleanup is best effort */ }
178
- }
189
+ cancelResponseBody(value);
179
190
  const response = new Response(null, { status: value.status, statusText: value.statusText, headers: value.headers });
180
191
  return responseBodyClass(value) === 'opaque'
181
192
  ? markOpaqueResponse(response, responseHeaderPairs(value))
@@ -218,7 +229,7 @@ async function dispatchRouter(router, frame, startIndex, activeError) {
218
229
 
219
230
  if (current.kind === 'mount') {
220
231
  const match = matchRoutePath(current.path, frame.relativePath);
221
- if (!match) {
232
+ if (!match || (current.eligibility && frame.state.get(current.eligibility.state) !== current.eligibility.equals)) {
222
233
  index += 1;
223
234
  continue;
224
235
  }
@@ -304,10 +315,12 @@ async function runNormalHandler(_router, frame, index, handler, routeContext) {
304
315
  if (handlerError === undefined) handlerError = error;
305
316
  }
306
317
  frame.signal?.throwIfAborted();
318
+ if (handlerError !== undefined && frame.executionOptions.outputExecution?.started) throw handlerError;
307
319
  if (handlerError !== undefined) {
308
320
  return Object.freeze({ kind: 'continue', index: index + 1, error: containUnexpected(handlerError, frame), frame });
309
321
  }
310
322
 
323
+ frame.executionOptions.outputExecution?.validateResult(output);
311
324
  const transfer = transferData(output, transferFactory.token);
312
325
  if (transferFactory.wasCalled()) {
313
326
  if (!transfer || output !== transferFactory.transfer()) {
@@ -358,10 +371,12 @@ async function runErrorHandler(_router, frame, index, activeError, handler) {
358
371
  if (handlerError === undefined) handlerError = error;
359
372
  }
360
373
  frame.signal?.throwIfAborted();
374
+ if (handlerError !== undefined && frame.executionOptions.outputExecution?.started) throw handlerError;
361
375
  if (handlerError !== undefined) {
362
376
  return Object.freeze({ kind: 'continue', index: index + 1, error: containUnexpected(handlerError, frame), frame });
363
377
  }
364
378
 
379
+ frame.executionOptions.outputExecution?.validateResult(output);
365
380
  const transfer = transferData(output, transferFactory.token);
366
381
  if (transferFactory.wasCalled()) {
367
382
  if (!transfer || output !== transferFactory.transfer()) {
@@ -419,9 +434,11 @@ async function executeRouter(router, request, options = {}) {
419
434
  options = { ...options, requestBudget: budget, signal: budget.signal, deadlineMonotonicMs: budget.deadlineMonotonicMs ?? options.deadlineMonotonicMs, kvClock: budget.deadlineMonotonicMs === undefined ? options.kvClock : budget.clock };
420
435
  const executionSignal = budget.signal;
421
436
  let effectExecution;
437
+ let transferredResponse;
422
438
  try {
423
439
  budget.check();
424
440
  effectExecution = createJavascriptEffectExecution({
441
+ outputExecution: options.outputExecution,
425
442
  effectAdapter: options.effectAdapter,
426
443
  capabilities: options.capabilities,
427
444
  application: options.application,
@@ -468,7 +485,7 @@ async function executeRouter(router, request, options = {}) {
468
485
  budget.check();
469
486
  const result = await budget.race(dispatchRouter(router, frame, 0, NO_ERROR));
470
487
  executionSignal?.throwIfAborted();
471
- if (result.kind === 'response') return result.response;
488
+ if (result.kind === 'response') return (transferredResponse = result.response);
472
489
  if (result.error !== NO_ERROR) {
473
490
  return new Response('Internal Server Error', {
474
491
  status: 500,
@@ -481,7 +498,7 @@ async function executeRouter(router, request, options = {}) {
481
498
  });
482
499
  } finally {
483
500
  try {
484
- await effectExecution?.close();
501
+ await effectExecution?.close(transferredResponse);
485
502
  } finally {
486
503
  if (ownsBudget) budget.close();
487
504
  if (effectExecution && typeof options.onEffectSummary === 'function') options.onEffectSummary(effectExecution.summary());
@@ -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(