@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 +2 -2
- package/docs/API.md +29 -18
- package/docs/guides/routing.md +43 -1
- package/docs/preview-scope.md +15 -11
- package/package.json +2 -2
- package/src/host.d.ts +39 -0
- package/src/host.js +2 -0
- package/src/index.d.ts +16 -2
- package/src/internal/body.js +4 -1
- package/src/internal/context.js +56 -7
- package/src/internal/effect-adapter.js +41 -9
- package/src/internal/fetch.js +3 -2
- package/src/internal/incoming-body.js +68 -0
- package/src/internal/package-runtime.js +4 -0
- package/src/internal/redaction.js +9 -1
- package/src/internal/request-budget.js +8 -5
- package/src/internal/response.js +32 -5
- package/src/internal/router.js +25 -8
- package/src/internal/schema.js +11 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
525
|
-
[GRIP package guide](https://pulsecompute.io/v1.0.0-beta.
|
|
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.
|
|
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.
|
|
592
|
-
model](https://pulsecompute.io/v1.0.0-beta.
|
|
593
|
-
example](https://pulsecompute.io/v1.0.0-beta.
|
|
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.
|
|
611
|
-
reference](https://pulsecompute.io/v1.0.0-beta.
|
|
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/).
|
package/docs/guides/routing.md
CHANGED
|
@@ -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.
|
|
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()`.
|
package/docs/preview-scope.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Beta scope
|
|
2
2
|
|
|
3
|
-
Pulse `1.0.0-beta.
|
|
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.
|
|
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
|
-
-
|
|
81
|
-
|
|
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
|
-
-
|
|
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.
|
|
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.
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/src/internal/body.js
CHANGED
|
@@ -200,7 +200,10 @@ function createStructuredBodyReader(owner, options = {}) {
|
|
|
200
200
|
|
|
201
201
|
function bytes() {
|
|
202
202
|
assertInspectable();
|
|
203
|
-
if (!bytesPromise)
|
|
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
|
|
package/src/internal/context.js
CHANGED
|
@@ -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
|
|
190
|
+
const fetchSequence = (fetchSequences.get(effects) || 0) + 1;
|
|
191
|
+
fetchSequences.set(effects, fetchSequence);
|
|
173
192
|
const effectId = `fetch-${fetchSequence}`;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
};
|
package/src/internal/fetch.js
CHANGED
|
@@ -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 = () =>
|
|
60
|
-
|
|
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.
|
|
87
|
+
listeners.clear();
|
|
85
88
|
}
|
|
86
89
|
});
|
|
87
90
|
budgets.add(budget);
|
package/src/internal/response.js
CHANGED
|
@@ -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
|
-
|
|
359
|
-
if (!(response instanceof Response) || !response.body || response.
|
|
360
|
-
|
|
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)
|
|
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,
|
package/src/internal/router.js
CHANGED
|
@@ -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
|
-
|
|
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());
|
package/src/internal/schema.js
CHANGED
|
@@ -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
|
-
|
|
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(
|