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