Pulse 1.0.0-beta.2 betaExact, immutable release documentation.Browse latest
Documentation platform maintainersactiveReviewed 2026-09-13Review by 2027-01-12

Effects and continuations#

A Pulse handler is written as async-shaped TypeScript. Host operations such as fetch, KV, secrets, and GRIP are awaited in source, but Native targets do not link a JavaScript Promise runtime. The compiler erases the managed async wrapper and trusted Pulse awaits into effects and the code that follows into continuations. JavaScript targets execute the same source through the live runtime and provider-owned effect adapter.

Application authors do not create or resume continuation objects. They write canonical TypeScript; the compiler and provider runtime own the lifecycle.

Effect#

An effect is an explicit request for host authority. Each effect has an identity, capability, operation, resource, source position, and result shape. Common examples include:

  • dispatching an outbound fetch;
  • reading config or a secret;
  • getting or putting a KV value;
  • accepting one outbound event through ctx.emit;
  • declaring a GRIP channel;
  • holding or publishing through GRIP.

Pure value construction, branching, property reads, and arithmetic are not effects.

Continuation#

A continuation identifies the compiled work that can proceed after one effect—or one effect group—settles. It carries no application-accessible provider object. The runtime resumes it with a normalized result or failure.

compiled values
    │
    ├── effect request ──► provider/runtime
    │                         │
    └── continuation ◄────────┘ normalized result or failure

A continuation is single-use and time-bounded. Expired or duplicate resume attempts fail with stable diagnostics such as PULSE_CONTINUATION_EXPIRED and PULSE_CONTINUATION_DOUBLE_RESUME.

Independent effects form a group#

The multi-fetch example declares three independent fetch values before consuming any of them:

import { Pulse } from '@pulse-compute/pulse'

interface User {
  id: number
  name: string
}

interface Stats {
  score: number
}

interface Flags {
  enabled: boolean
}

const app = new Pulse({ auto: true })

// One structured origin.
app.get('/user', async (ctx) => {
  const user = await ctx
    .fetch('https://users.example.test/users/123')
    .json<User>()
  return ctx.json({ found: true, user })
})

// Multiple origins with explicit sequential awaits.
app.get('/user-summary', async (ctx) => {
  const user = await ctx
    .fetch('https://users.example.test/users/123')
    .json<User>()
  const stats = await ctx
    .fetch('https://stats.example.test/users/123')
    .json<Stats>()
  const flags = await ctx
    .fetch('https://flags.example.test/users/123')
    .json<Flags>()
  return ctx.json({
    id: user.id,
    name: user.name,
    score: stats.score,
    enabled: flags.enabled,
  })
})

// Multiple origins with explicit portable concurrency.
app.get('/user-summary-parallel', async (ctx) => {
  const { user, stats, flags } = await ctx.parallel({
    user: ctx.fetch('https://users.example.test/users/123').json<User>(),
    stats: ctx.fetch('https://stats.example.test/users/123').json<Stats>(),
    flags: ctx.fetch('https://flags.example.test/users/123').json<Flags>(),
  })
  return ctx.json({
    id: user.id,
    name: user.name,
    score: stats.score,
    enabled: flags.enabled,
  })
})

export default app
pulse inspect examples/03-fetch-composition --json

The compiler can issue those fetches as one effect group because none depends on another result. The continuation becomes eligible only after the group settles.

Grouping provides concurrency without linking a general Promise runtime into Native artifacts. Source declaration order remains deterministic for identities and result binding, but providers may perform independent operations concurrently.

Explicit portable groups with ctx.parallel#

Native Pulse may discover independent adjacent effects and place them in one group for performance. Direct JavaScript execution does not rewrite ordinary await semantics, so separate awaits remain sequential there. Use ctx.parallel({ ... }) when concurrency is required application behavior across targets:

const { profile, flags } = await ctx.parallel({
  profile: ctx.fetch(profileUrl).json<Profile>(),
  flags: ctx.fetch(flagsUrl).json<Flags>(),
})

The first contract accepts a nonempty inline object literal with fixed, non-index string keys and Pulse effect expressions as values. Property order owns dispatch identity, trace order, deterministic primary-failure selection, and keyed result reconstruction. Providers may complete members in any order, but every member settles before the continuation resumes.

On JavaScript, all members execute through one execution-owned shared effect adapter. On Native, lowering erases ctx.parallel, emits the member operations into one canonical effect group, and reconstructs the ordinary keyed result object after one continuation. Arrays, spreads, computed keys, dynamic records, arbitrary promises, reused roots, and nested groups are outside the initial portable shape.

One-way outbound events#

HTTP and event handlers share the direct JavaScript ctx.emit effect:

await ctx.emit('device.led.set', {
  schema: 'events.DeviceLedSet',
  payload: { enabled: true },
})

await ctx.emit('system.tick', { schema: null })

The event type and schema are static compiler inputs. A non-null schema requires payload; schema: null forbids it. The runtime schema-validates and detaches the canonical frame before dispatch, and the effect resolves only when the configured host adapter accepts that frame. It returns undefined: there is no delivery receipt, correlation ID, handler result, retry guarantee, or local loopback.

ctx.emit may be awaited directly or used as a fresh member of an awaited ctx.parallel group. It consumes the same execution effect budget, cancellation, redaction, and disposal boundary as fetch, config, secret, and KV effects. The JavaScript dispatch uses the execution-owned effect adapter. Native lowering emits the same canonical event.emit descriptor, suspends on the ordinary continuation protocol, and resumes after host acceptance; it adds no JavaScript, Promise, or Asyncify runtime. The Node provider has a bounded, invocation-scoped FIFO reference adapter for direct JavaScript/Native ingress and exact accepted frame evidence. Its outbound ledger never loops back into ingress, and its success still promises no delivery, persistence, retry, receipt, or public event-bus behavior. No other provider realization or automatic fallback is claimed.

One-way acceptance is also the recursion boundary. Pulse exposes no ctx.call, generic request/reply effect, automatic local dispatch, or reserved call opcode. A future reflexive mechanism cannot be inferred from ctx.emit; it requires a separately specified host and lifecycle. See Static events and outbound emission.

Dependencies split continuation stages#

When later control flow or a later host operation depends on an earlier result, the compiler creates another continuation stage. For example, inspecting a first response and choosing a second URL is dependent work; it cannot be moved into the first independent group.

The rule is semantic rather than stylistic:

  • independent operations may share one group;
  • operations that need prior values start in a later continuation;
  • pure computation between effects stays in the continuation that owns it;
  • returning a final response completes the request lifecycle.

Use pulse inspect --json to see the actual grouping rather than inferring it from line spacing.

Failure behavior#

An effect failure is normalized into a stable public error. Examples include:

For an independent group, the runtime settles the group and reports the normalized failures through the owning continuation boundary. Userland does not receive partially live provider handles or background tasks that can outlive the request.

Timeouts have two scopes#

A fetch can have an operation timeout through PulseFetchInit.timeoutMs. The request runtime also owns a continuation lifetime configured by the execution environment or test case. These are different controls:

  • the operation timeout bounds one host operation;
  • the continuation TTL bounds how long compiled execution may remain suspended before resumption is rejected.

Project tests can set continuationTtlMs in a case inside the dedicated tests/pulse.harness.ts module for deterministic failure coverage.

Managed async, not arbitrary async#

Write managed handlers with async and await trusted Pulse effects. On Native targets, the compiler erases that notation into effects and continuations. On JavaScript targets, the live runtime executes the same async-shaped handler normally.

Arbitrary Promise construction, ambient asynchronous APIs, and unrecognized library awaits remain outside Native eligibility. They must not be mistaken for Pulse effects or silently trigger target fallback.

This design keeps:

  • effects visible to the compiler;
  • provider capability checks complete before execution;
  • failures and timeouts normalized;
  • request completion bounded;
  • Node and Fastly behavior comparable.

Inspect and test the lifecycle#

A useful workflow is:

pulse inspect ./my-app --json
pulse test ./my-app --json

inspect proves compilation, effects, grouping, continuations, and provider lowering without executing application test cases. test executes configured cases through the selected provider’s local conformance runtime.

Conditional KV#

ctx.kv<T>('name') exposes getVersioned(key), insertIfAbsent(key, value), and compareAndSwap(key, generation, value) as direct awaited or keyed parallel effects. The Node reference implements the contract for JavaScript and compiled Native control flow. Fastly Native implements it through direct KV hostcalls; Fastly JavaScript conditional KV remains explicitly incomplete.

The condition and mutation are atomic at one key's authority. A versioned read returns a value and an opaque generation from the same observation, which may be stale. Generations are strings, not numbers, hashes or application versions. Create conflicts when present; CAS conflicts when absent or when its token is stale. Equal-byte replacement is still a write and changes its generation. Existing unconditional get and put remain available.

A write returns stored, conflict, not-stored with a bounded reason, or unknown with a bounded reason. Unknown may have committed. A complete explicit provider rejection establishes no write; an exception from a send primitive does not. Pulse does not retry, reread-and-rebase, or invent a generation from a write acknowledgement. A subsequent missing or stale read cannot resolve an ambiguous write by itself.

Admission detaches and freezes the candidate before provider preparation. The host's monotonic ten-second deadline starts at admission and is shortened by an owning request deadline. Preparation may resolve bindings and stage data but cannot send. Entry into the returned provider primitive marks dispatch even if it throws synchronously. Timeout before that boundary is not-stored; timeout or transport loss after it is unknown. Cancellation suppresses delivery to an inactive execution and records whether dispatch occurred; it promises no rollback.

Exact keys are bounded Unicode scalar strings; generations are bounded visible ASCII strings. Values retain the portable KV JSON limits. Conditional storage uses a strict UTF-8 JSON envelope with exactly __pulseKv: 1 and value, rejecting duplicate members, invalid values, and oversized bodies. The wrapper is not recursively unwrapped. Legacy raw JSON requires explicit migration. Keys, tokens, and values are omitted or redacted from runtime evidence and managed errors; application responses are not silently rewritten.

Node reference instances retain nonreused generation identities for their own lifetime. Host code can explicitly reuse one reference across requests and target runners; independent instances isolate their state. Deterministic identities, clocks, and failure hooks belong to host evidence, not handler authority. This local model does not establish durability, global visibility, cross-key transactions, or a portable physical-delete/recreate guarantee. Fastly SDK limitations cannot redefine this contract or gate other targets.

Fastly Native preserves all 64 generation bits in an opaque string. ADD and generation-conditioned overwrite remain distinct operations, including an explicit condition for zero bits. The adapter stages the complete envelope before dispatch, uses monotonic deadlines and readiness selection before pending waits and body reads, and closes acquired bodies. It accepts up to 65,560 wire bytes, including wrapper overhead. Provider key restrictions return invalid-key without changing the key. Pending KV operations have no cancellation hostcall; an expired operation is abandoned to invocation teardown and may still commit. Host termination never resumes an inactive handler. Local Wasm evidence is separate from K4's deployed cross-location acceptance.

Browse other documentation