Build a portable microservice runtime in TypeScript using the UMA pattern. This tutorial walks through the Post Fetcher Runtime from Chapter 5: contract enforcement, adapter binding, event ordering, and parity with the Rust implementation.
This tutorial covers building wasm microservices in TypeScript using the UMA runtime discipline. The Post Fetcher Runtime is a complete UMA example: a TypeScript runtime that hosts a pure service, enforces its contract, binds the network.fetch adapter, and records lifecycle metadata. It is Chapter 5 of the UMA book rendered as runnable code.
By the end of this tutorial you will be able to:
Explain what belongs in the service layer versus what belongs in the runtime layer
Run the four guided labs and read the deterministic event log they produce
Show how compare_impls.sh proves behavioral equivalence between the TypeScript and Rust runtimes
Describe how the lifecycle record captures which adapter implementation actually ran
The TypeScript runtime in ts/ is a reference implementation kept in parity with the Rust workspace for the core Chapter 5 scenarios. It demonstrates that UMA runtime anatomy is a design discipline, not a language feature. The same contract, the same event ordering, the same adapter binding model. in TypeScript instead of Rust.
Prerequisites
Rust 1.77 or newer plus cargo: the validated Chapter 5 path is Rust-first. the parity comparison requires both runtimes to run
Node.js 20+ and npm: required for the TypeScript reference runtime in ts/
jq: required for the guided lab checks and golden-fixture comparisons. the scripts fail fast with an explicit message if it is missing
The browser and edge host sketches under adapters/network/ts-host/ and hosts/ are illustrative, not validated quick-start paths. You do not need any cloud credentials or external network access: the validated path resolves a checked-in fixture through the host adapter.
Project structure
The repository root is a Rust workspace. Each directory corresponds to a layer in the UMA runtime anatomy:
service/: pure normalization logic and service-facing API types. No I/O, no host dependency. This is the portable business logic that must produce identical results regardless of runtime.
contracts/: typed boundary definitions: the adapter capability contract (adapter.network.contract.json), the runtime policy (policy.runtime.json), and the lifecycle metadata schema (metadata.schema.json). These files are the declared contract, not generated artifacts.
adapters/: the network.fetch capability binding plus illustrative TypeScript and browser adapter scaffolding. The runtime selects which adapter implementation to use. the adapter directory defines what implementations exist.
hosts/: cloud and edge host shims. The cloud host is the validated path. The edge host is an illustrative sketch that fails fast with guidance rather than pretending it is turnkey.
ts/: the TypeScript reference runtime. Kept in parity with the Rust workspace for the core Chapter 5 scenarios. Tested with npm test --prefix ts.
runtime/: runtime orchestration, adapter binding, event bus, lifecycle record, and native CLI entrypoint. This is what the labs exercise.
labs/: lab notes. See labs/README.md for the guided walkthrough.
The separation between service/ and runtime/ is the central UMA discipline this chapter demonstrates. The service does not know which adapter ran. The runtime does not contain business logic. The contract layer in contracts/ makes the boundary machine-readable.
The runtime's job
A UMA runtime has three responsibilities, all visible in the Chapter 5 output:
Adapter selection and recording. The runtime chooses which implementation satisfies the network.fetch capability and records that decision in the lifecycle binding. The UMA_ENABLE_RETRY and UMA_ENABLE_CACHE environment variables change the binding record from host-fetch to cache-retry-host-fetch without changing the normalized service output.
Fail-fast validation. The runtime validates the request before any side effect. An invalid accept header stops the run before the first fetch_request event is emitted. The service never sees the request.
Deterministic event ordering. The runtime emits events in a fixed sequence using a logical clock that increments once per event. The TypeScript runtime in ts/ implements and preserves this same ordering, which is why compare_impls.sh can compare the two implementations against the same inputs.
The ts/ directory is a clean implementation of these three responsibilities in TypeScript. It is not a port of the Rust code. it is an independent implementation of the same runtime model. That independence is what the parity check proves.
Running the labs
Start by listing available labs:
./scripts/list_labs.sh
Then run each lab in order:
# Install TypeScript runtime dependencies
cd ts && npm install && cd ..
# Run each lab (TypeScript reference runtime)
./scripts/run_lab.sh lab1-cloud-golden-path
./scripts/run_lab.sh lab2-header-validation-fail-fast
./scripts/run_lab.sh lab3-adapter-binding-and-wrappers
./scripts/run_lab.sh lab4-rust-ts-parity
# Run TypeScript unit tests separately
npm test --prefix ts
# Build the Rust workspace (required for parity comparison)
cargo build --release -p post_fetcher_runtime
# Run the smoke path (builds Rust, installs TS deps, runs all labs)
./scripts/smoke_runtime_labs.sh
# Prove behavioral equivalence between Rust and TypeScript runtimes
./scripts/compare_impls.sh
# Run Rust unit tests
cargo test --locked
What each lab exercises:
lab1-cloud-golden-path: runs the validated cloud host path and compares the output against the checked-in golden fixture. The expected log signal is Integration test passed: output matches golden fixture.
lab2-header-validation-fail-fast: feeds an invalid accept header into the native CLI path and proves that validation stops the run before any fetch_request event is emitted. This is the clearest demonstration of fail-fast contract enforcement.
lab3-adapter-binding-and-wrappers: enables the retry and cache wrappers via environment variables and verifies that the runtime binding record changes to cache-retry-host-fetch. The normalized post output is identical to lab1. only the binding record changes.
lab4-rust-ts-parity: runs both the Rust and TypeScript runtimes against the validated Chapter 5 scenarios and compares their summarized runtime behavior. This is the parity proof.
After lab3, you should be able to explain how the runtime validates input, chooses an adapter implementation, and records that decision without changing the pure service logic. That is the stated satisfaction point for Chapter 5.
To run the full validated smoke path:
./scripts/smoke_runtime_labs.sh
Expected terminal signals: Integration test passed: output matches golden fixture. and Chapter 5 smoke run completed successfully.
The parity proof
The parity check is what distinguishes Chapter 5 from a standard tutorial:
./scripts/compare_impls.sh
This script runs both the Rust workspace and the TypeScript reference runtime against the same validated Chapter 5 inputs and compares their summarized runtime behavior. Behavioral equivalence is the proof of portability. If two independently written runtimes (one in Rust, one in TypeScript) produce the same event log, the same binding record, and the same normalized output for the same inputs, then the portable behavior lives in the service and the contract, not in the runtime implementation.
This is a property you can verify, not a claim you have to take on faith. The compare_impls.sh script makes the check reproducible and CI-friendly. The TypeScript runtime tests are separately verifiable with npm test --prefix ts.
The runtime is deliberately deterministic: no timers or random values influence event ordering. The logical clock increments once per emitted event. These constraints are what make the comparison tractable.
What the TypeScript path demonstrates
A runtime does not have to be a WASM host to participate in the UMA model. The TypeScript runtime in ts/ is a Node.js process. It hosts the same pure service logic, enforces the same contract, and produces the same observable behavior. The portable behavior lives in the service and the contract. the runtime is an adapter to a different host ecosystem.
This has a practical implication: the UMA discipline applies at the architecture level, not at the WASM toolchain level. Teams working in TypeScript ecosystems can adopt the runtime model (contract enforcement, adapter binding, deterministic event ordering, lifecycle recording) without compiling anything to WASM. The WASM compilation step adds the strongest portability guarantee (run anywhere without recompilation), but the architecture discipline delivers value independently of that step.
Same contract. Different host. Provably equivalent behavior. That is the point of ts/.
Why TypeScript for WASM microservices
Most WebAssembly tutorials default to Rust because Rust has the most complete WASM toolchain, the smallest output binaries, and the best WASI 0.2 support. But TypeScript teams do not need to switch languages to adopt the WASM microservices architecture discipline. The UMA model separates the portable service layer from the runtime adapter layer. That separation applies regardless of whether the service core compiles to a .wasm binary or runs as a Node.js module.
What the TypeScript path demonstrates is that the architecture discipline (not the WASM binary format) is the core value. A TypeScript microservice that enforces its contract at the boundary, records adapter decisions in a lifecycle log, and validates inputs before emitting side effects is architecturally equivalent to a Rust WASM module. The portable behavior lives in the service contract. The runtime is an implementation detail.
For teams that do need to cross runtime boundaries (running the same logic in a browser and on a server) the step from this TypeScript runtime to a compiled WASM binary is additive. The contract, the adapter model, and the event ordering discipline transfer directly. You are not rewriting. you are compiling the service core to a different target.
Contract enforcement in practice
The contract layer in contracts/ is the most important output of this tutorial. Three files define the boundary:
adapter.network.contract.json: specifies what the network.fetch capability must accept and return. Any adapter implementation that satisfies this contract is interchangeable. The runtime does not care whether the adapter makes a real HTTP request, hits a fixture, or invokes a cache. It cares that the adapter returns a response that matches the schema.
policy.runtime.json: declares which adapter implementations are allowed, what retry and cache wrappers are permitted, and what environment variables can change the binding. This is the governance layer. It makes the adapter selection rules explicit rather than implicit in application code.
metadata.schema.json: defines the shape of the lifecycle record that every run produces. The record captures which adapter was selected, which wrappers were applied, and the event sequence. This is the audit surface.
These contracts are not generated from code. They are the specification that both the Rust and TypeScript implementations must satisfy. The compare_impls.sh script verifies that they do. A drift between the two implementations shows up as a diff, not as a runtime surprise in production.
Next steps
After completing this tutorial:
Compare with the Rust runtime: the Rust workspace is the validated default path for Chapter 5. Reading both implementations side by side clarifies which choices are language-specific and which are architecture-level constraints. See the WASM Microservices Tutorial: Rust.
Chapter 6: UMA Portability Lab: the next chapter takes the same portable service and runs it across multiple host environments in a single lab, making the portability guarantee concrete rather than theoretical.
Active Descriptors: the contract layer in contracts/ is a simplified version of the full active descriptor model. See the Active Descriptors page for the complete governance and placement model that builds on this foundation.
TypeScript runtime role
The ts/ runtime enforces the same three responsibilities as the Rust runtime: adapter selection and recording, fail-fast validation before side effects, and deterministic event ordering via a logical clock. It is an independent implementation of the runtime model, not a port. Its existence proves the model is a design discipline, not a Rust API.
Portability proof
./scripts/compare_impls.sh runs both runtimes against the same inputs and compares their outputs. Behavioral equivalence between two independent implementations (one in Rust, one in TypeScript) is machine-verifiable evidence that the portable behavior lives in the service and the contract. The runtime is an adapter. the contract is the constant.
Source code and Rust tutorial
The full Chapter 5 source code, including the TypeScript reference runtime, all lab scripts, and the golden fixtures, is on GitHub. The Rust tutorial covers the same chapter from the validated Rust-first path.