Structured and opaque bodies#
Application-owned JSON strings can be decoded synchronously with ctx.decodeJson<T>(text, 'schema.id'). The registered schema and UTF-8 size limit apply without fetching the body again. Keep the original string for byte hashes and retries; a decoded value is a detached schema projection. See application text decoding.
Pulse distinguishes bodies that application code may inspect from bodies that must remain host-owned. That distinction is part of the provider-neutral contract and is visible in types, compiler metadata, tests, and diagnostics.
A useful rule is:
Inspect it as a bounded structured value, or pass it through as an opaque response. Do not silently switch between the two.
Ownership transitions#
All network body bytes begin under host or provider ownership. A supported Pulse operation either converts them once into bounded request-owned data or preserves an opaque host-owned handle. Ownership never moves through a provider SDK object in userland.
| Boundary | Owner before | Application operation | Owner after |
|---|---|---|---|
| Incoming request text/JSON | Request host owns body bytes | ctx.req.text() or ctx.req.json() | Current request owns the bounded structured value and read cache. |
| Fetched response text/JSON | Provider adapter owns response bytes | .text() or .json() on the fetch operation | Current request owns the bounded structured projection. |
| Outbound fetch JSON | Application owns a supported structured value | ctx.fetch(url, { json, schema }) | Pulse encodes the semantic value; the provider owns dispatched body bytes. |
| Application-owned JSON text | Application owns a structured value | ctx.encodeJson(value, 'schema.id') | Application owns detached schema-encoded text within schemas.maxBytes. |
| Application text/JSON response | Application owns a supported structured value | ctx.text(), ctx.json(), or ctx.response() | Pulse returns a terminal result; the provider owns response realization. |
| Opaque fetch or package response | Provider owns the body handle | Return the response directly | Provider retains ownership through terminal pass-through. |
Request-owned values and caches end with that request. They cannot be retained for background work. Provider-owned opaque handles stay opaque: they cannot be converted into a structured body. Structured values cannot be promoted into a userland stream.
Structured request bodies#
ctx.req.text() and ctx.req.json() read a bounded request body. JSON can be decoded generically or against an explicitly compiled schema. Repeated schema reads are deterministic within one request.
On Node Native, Node JavaScript and Fastly Native, request text is strict UTF-8. The original encoded-byte limit is enforced before decoding. Malformed, truncated, overlong, surrogate and out-of-range encodings fail with PULSE_REQUEST_BODY_INVALID_UTF8 before text reaches the application. Valid replacement characters, a leading U+FEFF, and scalars split across transport chunks are preserved without normalization. An application error handler may map this request-data failure to its own bounded 400 response. Without that handler, Node JavaScript retains its exhausted-error-lane 500 response; the Native HTTP boundaries reject malformed request text with 400. Transport size failures remain 413. A schema projection does not establish rejection of duplicate or unknown properties; that validation policy remains application-owned.
The schema example decodes one body twice and proves that the request-local decoded value is reused:
import { Pulse } from '@pulse-compute/pulse'
import type { CreateUserInput, CreateUserOutput } from './schemas.js'
const app = new Pulse({ auto: true })
app.post('/users', async (ctx) => {
const first = await ctx.req.json<CreateUserInput>('app.CreateUserInput')
const second = await ctx.req.json<CreateUserInput>('app.CreateUserInput')
const output: CreateUserOutput = {
id: 7,
name: first.name,
active: first.active,
sameReference: first === second,
}
return ctx.json(output, { status: 201, schema: 'app.CreateUserOutput' })
})
export default app
pulse test examples/02-request-schema --case valid-user --json
Structured body limits are configured through schemas.maxBytes, dev.maxBodyBytes, or a test case’s maxBodyBytes, depending on the read path. Oversized or invalid input fails before unbounded materialization with diagnostics such as:
A successful schema decode returns a normalized, deeply immutable value. Repeated request reads with the same schema ID reuse that request-local decoded value. Generic JSON is also bounded, but it does not gain a typed schema contract.
Structured fetch responses#
A fetch response can be inspected through normalized status, headers, text(), and json<T>(). Once application code asks for text or JSON, the body is treated as a bounded structured value governed by decoding and size policy.
const user = await ctx.fetch('https://api.example.test/user').json<{ id: number; name: string }>()
return ctx.json({ found: true, user })
The application receives portable data, not a provider SDK response object.
Schema-bound fetched JSON uses the same codec and content-type policy as schema-bound request JSON. A read consumes the response into a structured projection for the current request; it does not expose a reusable provider stream.
Opaque response pass-through#
Some responses should cross Pulse without being copied, decoded, or exposed to userland—archives, media, streaming responses, and provider-owned hold responses are examples. Return the fetch response directly:
import { Pulse } from '@pulse-compute/pulse'
const app = new Pulse({ auto: true })
app.get('/archive', async (ctx) => {
return ctx.fetch('https://assets.example.com/archive.bin')
})
export default app
pulse inspect examples/07-opaque-proxy --json
{
"status": "ok",
"provider": {
"id": "fastly"
},
"compiler": {
"effectCount": 1,
"continuationCount": 1,
"opaqueReturnCount": 1,
"providerLowering": {
"requirements": [
"fetch",
"opaque.pass-through"
]
}
}
}
Opaque pass-through preserves host ownership. Pulse may carry status and headers needed to complete the response, but application code cannot inspect chunks, decode the body, concatenate it, or retain it beyond the request lifecycle.
The JavaScript runtime cancels fetched bodies left behind when execution ends, including successful siblings of a failed effect group and bodies rejected before structured reading begins. Only the final returned body transfers to the host; normal execution cleanup preserves it. A response arriving after request cancellation or an operation timeout is cancelled without resuming application work. Body cancellation is best effort: a rejecting or stalled provider cleanup callback does not delay completion, including body suppression for HEAD.
Attempting to inspect an opaque body fails with PULSE_OPAQUE_BODY_INSPECTION. A missing or already-consumed structured body can fail with PULSE_BODY_UNAVAILABLE.
Incoming forwarding on Node#
Node Native and JavaScript can forward one incoming body to one outbound POST without materializing it. Opt in with node.bodyForwarding: { maxBytes: 67108864 } and node.maxDurationMs: 30000 in the selected project profile:
app.post('/upload', async (ctx) => {
return ctx.fetch('https://uploads.example.com/receive', {
method: 'POST',
body: ctx.req.body()
});
});
ctx.req.body() is a synchronous opaque marker, usable only inline in this literal POST form. It cannot be awaited, stored, duplicated, inspected or combined with request text/JSON reads. Ownership is reserved before provider dispatch; conflicting claims invalidate queued effects. A handler can reject the request before any incoming body read or outbound dispatch.
The provider reads on demand, emits at most 16 KiB per chunk, and retains at most one 64 KiB source chunk per direction. A source chunk or backing allocation larger than 64 KiB is rejected. These are Pulse pump bounds, not a total RSS or OS/socket buffer guarantee. maxBytes independently limits upload and response bytes, including bodies with unknown lengths. Declared lengths are checked at admission; measured bytes remain authoritative. The configured request deadline covers admission, forwarding and the Node response writer. A shorter fetch timeoutMs continues through the local response writer, including after source EOF.
Request headers are not copied implicitly. Caller-supplied framing, host and hop-by-hop headers are rejected. Redirects and retries never replay the body. An early origin response cancels the unfinished upload; client disconnect or deadline expiry cancels active work. A failure after response headers destroys the downstream connection. Opted-in POST responses close their HTTP connection so abandoned input does not require unbounded draining.
Experimental generated output on Node#
For a finite generated text response, opt in with node.generatedOutput: true and node.maxDurationMs in the selected Node profile:
app.get('/generated', async (ctx) => {
await ctx.output.start({ headers: { 'content-type': 'text/plain' } });
await ctx.output.write('first\n');
const message = await ctx.config.get('MESSAGE');
await ctx.output.write(message || 'done');
return ctx.output.close();
});
Both Native and JavaScript send writes incrementally. Await every start/write and return the close result directly. One request owns the output; writes cannot run in ctx.parallel. Each text chunk is limited to 16 KiB UTF-8, with at most 64 writes and 1 MiB total. Native also enforces cumulative retained-value and linear-memory limits; completing a write does not reclaim all earlier values. These limits do not promise constant process memory for arbitrary application code. Native output with linked guests remains rejected pending qualification.
Start commits the status and headers. HEAD, bodyless statuses, caller-controlled framing and hop-by-hop headers are rejected before commitment. After start, a failure destroys the transport rather than sending a second response. Close waits for local writer completion through the provider; the original deadline and disconnect handling remain active through finish. Local completion does not prove receipt by the client.
This surface remains experimental. STR-03B provides independent installed Node Native/JavaScript qualification for exact candidate bytes, including real HTTP failure handling and a separate controlled-writer backpressure check. Passing that task does not certify future artifacts or constitute a release seal. It does not qualify the separate input transform surface below, binary transforms, arbitrary stream/generator objects, Fastly output or MCP SSE.
Experimental bounded UTF-8 transforms on Node#
STR-03C adds await ctx.req.readTextChunk() on Native and JavaScript with node: { bodyTransform: true, generatedOutput: true, maxDurationMs: 5000 }. This selects strict UTF-8 text, including a preserved BOM. Malformed or incomplete UTF-8 fails; arbitrary binary bodies must use opaque forwarding instead.
app.post('/duplicate-blocks', async (ctx) => {
await ctx.output.start();
for (let i = 0; i < 18; i++) {
const chunk = await ctx.req.readTextChunk();
if (chunk.done) break;
await ctx.output.write(chunk.text + chunk.text);
}
return ctx.output.close();
});
The example duplicates each fixed block in Native code and has a measured 2× UTF-8 expansion. Identity, concatenation and the existing bounded pure expression subset are available; this adds no transformation callbacks or string methods. Blocks do not represent lines or records. Pulse collects at most 4,093 raw bytes per read, carries up to three incomplete UTF-8 bytes, and delivers at most 4,096 encoded bytes as text. These boundaries are independent of network fragmentation. A partial block waits for more input or EOF under the original request deadline. A separate { done: true, text: '' } result marks EOF. Eighteen reads suffice for the maximum input including EOF; further reads fail.
Input is capped at 65,536 actual bytes, independently of Content-Length. Output is capped at 262,144 bytes and four times the input bytes delivered so far. Empty input grants no output allowance. The generated writer's 16 KiB/write and 64-write limits still apply. Read through EOF, then return ctx.output.close(). No read may overlap a write, and blocked writes prevent subsequent input pulls. The shared deadline covers input, transformation effects, output and finish.
bodyTransform excludes bodyForwarding and structured req.text()/req.json() reads. Native rejects applications combining transform and structured body capabilities; JavaScript enforces the ownership conflict at runtime. Denial before any read remains lazy. Each request has its own reader, decoder and writer. There is no replay, tee, fetched-body cursor or background producer. Failures after output starts destroy the response under the generated-output contract.
The source chunk and backing allocation are each limited to 64 KiB. One retained source chunk, one 4,093-byte assembly block and at most three decoder carry bytes bound the provider input queue to 69,632 bytes; Node transport buffers are separate. Native retains its cumulative 64 MiB value-accounting and 4,096-page memory limits; completed reads/writes do not refund allocations. These are finite execution and queue bounds, not a constant-RSS or arbitrary JavaScript allocation guarantee. The str03c-bounded-transforms task checks actual Native execution, JavaScript parity, UTF-8 fragmentation, limits, cancellation, deterministic writer backpressure and real HTTP output before input EOF. It is workspace evidence; installed transform qualification and any support promotion remain separate. Fastly rejects this capability on both targets.
Current transport limits#
Both Fastly targets reject incoming forwarding. Node Native forwarding uses the emitted Wasm, with no JavaScript fallback. Its request text/JSON host calls are synchronous, so a Native forwarding application cannot also declare structured request reads; use a separate application for those endpoints. JavaScript retains per-request read/forward exclusion. Without the Node opt-in, existing bounded request buffering is unchanged. Input chunks remain opaque; the separate experimental generated-output API above accepts only application-owned text.
Handler completion, response-header commitment and stream completion are different boundaries. The Node JavaScript response writer waits for its local pipeline; the Native CLI opaque writer can return after starting a pipe. Neither fact alone establishes a portable queue bound, client receipt, or a deadline covering post-handoff streaming. The bounded HTTP deadline contract retains its explicit streaming exclusions for the pre-existing paths. The opted-in Node forwarding path above has its own completion-aware deadline.
Why the distinction matters#
The two body classes have different guarantees:
| Property | Structured | Opaque |
|---|---|---|
| Application can read text/JSON | Yes, within limits | No |
| Application can construct a replacement body | Yes, from supported values | No |
| Provider object enters userland | No | No |
| Body may remain host-owned | No | Yes |
| Suitable for binary/stream pass-through | No | Yes |
| Userland chunk iteration or transform | No | No |
Pulse does not infer that a body is safe to inspect merely because one provider could expose it. The same canonical source must retain equivalent meaning across supported providers.
Schema encoding#
ctx.encodeJson(value, 'namespace.Type') exposes schema encoding as bounded application-owned text, before a response or storage effect. It always requires a literal registered schema ID. The returned UTF-8 text is limited by schemas.maxBytes; invalid or oversized output fails before subsequent writes. Keep those exact bytes for upload and verification. See application-owned encoding for determinism and fingerprint boundaries.
ctx.json(value, { schema: 'namespace.Type' }) validates and encodes a structured response against the compiled schema contract. Schema identifiers must be static and declared in project configuration. Failures use PULSE_SCHEMA_ENCODE or PULSE_RESPONSE_ENCODE.
ctx.fetch(url, { json: value, schema: 'namespace.Type' }) applies the same semantic encoding boundary to an outbound request. Pulse encodes the value before provider dispatch; application code never receives the provider request body object.
See Explicit JSON schemas for the exact request, fetch, response, strict-mode, and response-case forms.
GRIP hold responses are opaque#
A package-owned grip.hold(...) operation returns an opaque response contract. The provider owns the hold/stream realization; canonical handler code may return it but may not inspect or transform its body. This is the same body boundary used by direct fetch pass-through.
Not supported in the Beta#
The public contract does not include:
- arbitrary binary body inspection;
- arbitrary userland stream readers or writers beyond the experimental finite Node output/UTF-8 transform APIs;
- general input chunk iteration, binary transforms or fetched-body cursors;
- buffering an opaque response into structured memory;
- provider-specific response objects;
- background consumption after the request completes.
These exclusions are listed in the Beta scope.