PlexSpaces supports deploying WebAssembly (WASM) applications from multiple languages (Rust, Python, TypeScript/JavaScript, Go) via HTTP multipart upload or gRPC, following industry best practices for large file uploads.
📖 For comprehensive polyglot development guide covering all languages, WIT abstractions, and examples, see Polyglot WASM Development Guide
Native Rust actors (embedded in the node, not compiled to WASM) use the Rust SDK: annotations such as #[gen_server_actor], #[handler], #[plexspaces_handlers], plus SDK spawn helpers like spawn, spawn_with_facets, and spawn_with_storage. For Rust WASM deployment, use the same SDK macro family in WASM mode (for example #[gen_server_actor(wasm)] and #[plexspaces_handlers(wasm)]) so the SDK generates the framework-owned WIT bindings instead of hand-written exports.
Quick Start: See DEPLOY_EMPTY_NODE_GUIDE.md for a complete workflow showing how to start an empty node, deploy a WASM application, and verify deployment via the dashboard.
Deployed WASM applications report node-local benchmark and runtime counters through
ApplicationMetrics. This is the source of truth for application-level summaries returned by
GetApplicationStatus.
ApplicationMetrics is intentionally extensible:
actor_counts: counts by role/type on the current nodemessage_count,error_count: node-local totalscounter_metrics: application-defined counters such asscatter_gather_roundsortuple_operationslatency_totals_ms,latency_max_ms,latency_samples: raw latency aggregates keyed by metric type such asworker.compute,worker.coordination, orleader
For multi-node WASM examples such as heat_diffusion, each node records its own metrics locally and
the leader aggregates them by calling application-get-status on all participating nodes. This
avoids any shared-database assumption and keeps per-node accounting aligned with deployment topology.
Namespace is now required for all WASM deployments. It scopes all actors within an application and is used to construct actor IDs.
- If not explicitly specified in the TOML config or API request, the namespace defaults to the application name.
- Actor IDs use the canonical format:
name//actor_type::namespace@node_id
Example: An application named my-app deployed to node node-1 without an explicit namespace will have actors with IDs like my-app//my-app::my-app@node-1.
Specifying in TOML config:
name = "my-app"
version = "1.0.0"
namespace = "my-app" # Required: all actors scoped to this namespaceSpecifying in API request (form field):
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=my-app" \
-F "name=my-app" \
-F "namespace=my-app" \
-F "version=1.0.0" \
-F "wasm_file=@my_actor.wasm"✅ WASM actors only use SDK/WIT-safe APIs - they do NOT embed host runtime services or hand-written ABI glue:
- ✅ SDK-generated WIT bindings: Actors call framework services through SDK wrappers generated from WIT
- ✅ Standard Library: Actors can use their language's standard library (e.g., Python's
json, Rust'sstd) - ❌ No Host Runtime Dependencies: WASM modules do NOT embed
plexspaces-node, Tokio runtimes, or direct gRPC clients - the framework is provided by the host runtime
Example (Python Actor):
# ✅ Correct - only uses standard library and WIT
import json # Standard library
def handle_request(from_actor: str, message_type: str, payload: bytes) -> bytes:
# Uses WIT host functions (provided by runtime)
# host.send_message(...) # WIT import
# host.tuplespace_write(...) # WIT import
# Uses standard library
request = json.loads(payload.decode('utf-8'))
return json.dumps({'result': 42}).encode('utf-8')Example (Rust Actor):
use plexspaces_sdk::{
gen_server_actor, handler, json, plexspaces_handlers, ActorContext, BehaviorError, Message,
Value,
};
#[gen_server_actor(wasm)]
struct CounterActor {
value: i64,
}
#[plexspaces_handlers(wasm)]
impl CounterActor {
#[handler("get")]
async fn get(&mut self, _ctx: &ActorContext, _msg: &Message) -> Result<Value, BehaviorError> {
Ok(json!({ "value": self.value }))
}
}Python-compiled WASM files are large (30-40MB) because:
componentize-pybundles the entire Python runtime- This is expected and normal for Python-to-WASM compilation
- The runtime is shared across all Python actors on a node
Size Comparison by Language:
| Language | WASM Size | Runtime Size | Use Case |
|---|---|---|---|
| Rust | 100KB-1MB | Minimal | Production, performance-critical |
| Go | 2-5MB | Small | Good balance |
| JavaScript/TypeScript | 500KB-2MB | Medium | Web integration |
| Python | 30-40MB | Large | Rapid prototyping, ML |
Size Reduction Options:
-
Use
wasm-opt(recommended):wasm-opt -Oz --strip-debug calculator_actor.wasm -o calculator_actor_opt.wasm # Typically reduces size by 20-40% -
Use Rust/Go/JavaScript instead of Python for smaller WASM files
-
Optimize Python code:
- Remove unused imports
- Use minimal dependencies
- Consider PyPy for smaller runtime (if supported)
Best Practice: Use HTTP multipart/form-data for large file uploads (>5MB), similar to document uploads in production applications.
Endpoint: POST http://localhost:8000/api/v1/applications/deploy
Content-Type: multipart/form-data
Body Size Limit: 100MB (configured via DefaultBodyLimit middleware in Axum)
Fields:
application_id(required): Unique application identifier (for tracking/debugging)wasm_file(required): WASM module file (multipart file upload)config(optional): ApplicationSpec TOML configuration filename(required): Human-readable application nameversion(required): Application version (e.g., "1.0.0")
Resource Limits:
- Fuel limits are configured via
WasmConfig.limits.max_fuel(default: 10 billion units) - For operations requiring heavy JSON serialization or complex computations, increase fuel limits
- Fuel is consumed during execution (ops, memory access, calls)
- Zero = unlimited (not recommended for untrusted code)
name(required): Human-readable application nameversion(required): Application version (e.g., "1.0.0")behavior_kind(optional): OTP-style behavior for logging (e.g.GenEventfor event-handler actors; logs showEventHandler)wasm_file(required): WASM file (multipart file upload, max 100MB)config(optional): Application config TOML file (if not provided, ApplicationSpec is auto-generated)
ApplicationSpec Auto-Generation:
If config is not provided, the HTTP handler automatically creates an ApplicationSpec from form fields:
name: Set toapplication_idso runtime identity, application namespace, and undeploy all use the same keynamespace: Set toapplication_idso actor registration and dashboard queries use the same canonical scopeversion: Fromversionform fieldtype:ApplicationTypeActive(active application with processes)description: Auto-generated as"WASM application: {name}"dependencies: Empty arrayenv: Empty map (can be set via config TOML)supervisor: None (can be set via config TOML)
ApplicationSpec Usage:
- The ApplicationSpec is passed to
WasmApplication::new()which implements theApplicationtrait - Used for supervisor tree initialization (if specified in config)
- Used for environment variables (if specified in config)
- Follows the Erlang-style application model where applications are the unit of deployment
- Matches the pattern used by the
wasm-calculatorexample, ensuring consistent application deployment
ApplicationManager stores the deployed application under application_id, and object-registry uses that same identifier for registration and cleanup.
Response:
{
"success": true,
"application_id": "calculator-app",
"status": "APPLICATION_STATUS_RUNNING",
"error": null
}Examples:
Python WASM (Calculator) - 39MB:
# CLI automatically uses HTTP for large files (>5MB)
./target/release/plexspaces deploy \
--node localhost:8000 \
--app-id calculator-app \
--name calculator \
--version 1.0.0 \
--wasm examples/simple/wasm_calculator/wasm-modules/calculator_actor.wasm
# Or use HTTP directly
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=calculator-app" \
-F "name=calculator" \
-F "version=1.0.0" \
-F "wasm_file=@examples/simple/wasm_calculator/wasm-modules/calculator_actor.wasm"Rust WASM - Small (<5MB):
# Build Rust WASM (e.g. from nbody_wasm wasm-actors or your crate)
cd examples/rust/embedded/nbody_wasm/wasm-actors
cargo build --target wasm32-wasip2 --release
# CLI uses gRPC for small files
./target/release/plexspaces deploy \
--node localhost:8000 \
--app-id rust-app \
--name rust-actor \
--wasm target/wasm32-wasip2/release/rust_actor.wasm
# Or use HTTP
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=rust-app" \
-F "name=rust-actor" \
-F "version=1.0.0" \
-F "wasm_file=@target/wasm32-wasip2/release/rust_actor.wasm"TypeScript WASM (actor-world WIT, recommended):
Use the TypeScript SDK: extend PlexSpacesActor, bundle with esbuild, then build with jco:
# From examples/typescript/apps/bank_account
npm run build # tsc + esbuild bundle
jco componentize account_actor_bundle.mjs --wit wit/plexspaces-actor -o account_actor.wasm --disable all
# Deploy via HTTP
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=bank-test-ts" \
-F "name=account" \
-F "version=1.0.0" \
-F "wasm_file=@account_actor.wasm" \
-F "config=@app-config.toml"Why HTTP over gRPC?
- ✅ Industry Standard: HTTP multipart is the standard for file uploads (S3, GitHub, Docker)
- ✅ No Size Limits: HTTP can handle files up to 100MB (configurable)
- ✅ Better Tooling: Works with
curl,wget, browsers, CDNs - ✅ No Global Impact: Doesn't require increasing gRPC message size limits for all APIs
- ✅ Resumable: Can implement chunked/resumable uploads in the future
Note: CLI automatically detects file size and uses the appropriate method:
- Files ≤5MB: Uses gRPC (faster, simpler)
- Files >5MB and ≤100MB: Automatically uses HTTP multipart upload
- Files >100MB: Returns error (optimize with wasm-opt first)
CLI Configuration:
- gRPC max message size: 5MB (configured to match server)
- HTTP max file size: 100MB (same as server limit)
- Automatic fallback: CLI automatically switches to HTTP for large files
Command:
./target/release/plexspaces deploy \
--node localhost:8000 \
--app-id calculator-app \
--name calculator \
--version 1.0.0 \
--wasm examples/simple/wasm_calculator/wasm-modules/calculator_actor.wasmWorks for: Small WASM files (<5MB), typically Rust or optimized JavaScript/Go
Examples:
Rust WASM (Small):
# Rust WASM files are typically <1MB, so CLI uses gRPC
./target/release/plexspaces deploy \
--node localhost:8000 \
--app-id rust-counter \
--name counter \
--wasm target/wasm32-wasip2/release/counter.wasmOptimized JavaScript:
# After wasm-opt optimization, JavaScript WASM can be <2MB
wasm-opt -Oz --strip-debug greeter.wasm -o greeter_opt.wasm
# CLI uses gRPC for small files
./target/release/plexspaces deploy \
--node localhost:8000 \
--app-id greeter-app \
--name greeter \
--wasm greeter_opt.wasmLarge Python WASM (39MB):
# CLI automatically detects large file and uses HTTP multipart
./target/release/plexspaces deploy \
--node localhost:8000 \
--app-id calculator-app \
--name calculator \
--wasm examples/simple/wasm_calculator/wasm-modules/calculator_actor.wasm
# Output: "⚠️ WASM file size (39.00MB) exceeds gRPC limit (5MB), using HTTP multipart upload"Undeploy uses application_id. The same identifier used during deployment is also used for runtime state and object-registry cleanup.
HTTP DELETE:
# Use the canonical application_id
curl -X DELETE http://localhost:8000/api/v1/applications/calculator-appCLI:
cargo run --release --bin plexspaces -- application undeploy \
--node localhost:8000 \
--name calculator-appIdentity Model
application_id: Canonical runtime identity for deploy, undeploy, namespace derivation, and object-registry registrationname: Human-readable label supplied by the client
Response:
{
"success": true,
"application_id": "calculator-app"
}Location: examples/simple/wasm_calculator/
Build:
cd examples/simple/wasm_calculator
./scripts/build_python_actors.shDeploy:
# HTTP (recommended for 39MB Python WASM)
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=calculator-app" \
-F "name=calculator" \
-F "version=1.0.0" \
-F "wasm_file=@wasm-modules/calculator_actor.wasm"Undeploy:
# Use application_id (not name) for undeployment
curl -X DELETE http://localhost:8000/api/v1/applications/calculator-appLocation: examples/rust/embedded/nbody_wasm/wasm-actors/ (or any crate with wasm32-wasip2 target)
Build:
cd examples/rust/embedded/nbody_wasm/wasm-actors
cargo build --target wasm32-wasip2 --releaseDeploy:
# CLI (works for small Rust WASM)
./target/release/plexspaces deploy \
--node localhost:8000 \
--app-id rust-app \
--name rust-actor \
--wasm target/wasm32-wasip2/release/rust_actor.wasm
# Or HTTP
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=rust-app" \
-F "name=rust-actor" \
-F "version=1.0.0" \
-F "wasm_file=@target/wasm32-wasip2/release/rust_actor.wasm"Location: examples/typescript/apps/bank_account/
Uses the TypeScript SDK and the same plexspaces-actor WIT as Python. Build with jco (not Javy) so the component imports only plexspaces:actor/host.
Key Features:
- SDK Handles WIT Types: WIT TypeScript types are generated automatically by the SDK - clients don't need to generate or import them
- Proto-first ABI: Actor-world exchanges protobuf wire bytes and typed WIT results instead of JSON payload strings
- Generated Models: SDKs encode/decode generated protobuf models at the boundary and keep business logic in the framework crates
Build:
cd examples/typescript/apps/bank_account
./scripts/build.sh # tsc → esbuild bundle → jco componentize --disable allDeploy:
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=bank-test-ts" \
-F "name=account" \
-F "version=1.0.0" \
-F "wasm_file=@account_actor.wasm" \
-F "config=@app-config.toml"Location: Use tinygo with wasip2 target. See examples/go/apps/ for Go WASM examples (e.g., examples/go/apps/migrating_gosiris/).
Build:
tinygo build -target=wasip2 -o go_actor.wasm go_actor.goDeploy:
# HTTP (recommended)
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=go-app" \
-F "name=go-actor" \
-F "version=1.0.0" \
-F "wasm_file=@go_actor.wasm"Endpoint: POST /api/v1/applications/deploy
Port: gRPC and HTTP share a single port (e.g., 8000 by default)
Max File Size: 100MB
Example:
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=calculator-app" \
-F "name=calculator" \
-F "version=1.0.0" \
-F "wasm_file=@calculator_actor.wasm" \
-F "config=@config.toml"For long-running operations (e.g., actors performing heavy computation or large data processing), you can specify a timeout in seconds using the ?timeout= query parameter. This overrides the default request timeout and keeps the connection open for the specified duration.
Usage: Append ?timeout=<seconds> to any actor API endpoint.
Example:
# Wait up to 30 seconds for the trainer actor to respond
curl "http://localhost:7993/api/v1/actors/my-app/trainer?timeout=30"Without the timeout parameter, the default HTTP timeout applies. Use this when interacting with actors that perform long-running tasks such as model training, batch processing, or complex simulations.
Endpoint: DELETE /api/v1/applications/{application_id}
Important: Use application_id for undeployment and dashboard correlation.
Example:
# Use application_id
curl -X DELETE http://localhost:8000/api/v1/applications/calculator-appEndpoint: plexspaces.application.v1.ApplicationService/DeployApplication
gRPC Message Size Limit: 5MB (configured on both server and CLI client)
CLI Support:
- Files ≤5MB: CLI uses gRPC (fast, efficient)
- Files >5MB and ≤100MB: CLI automatically uses HTTP multipart (seamless fallback)
- Files >100MB: CLI returns error with optimization suggestion
CLI Configuration:
- gRPC max message size: 5MB (matches server)
- HTTP max file size: 100MB (matches server)
- Automatic detection: CLI checks file size and chooses appropriate method
Example (using grpcurl):
grpcurl -plaintext \
-d '{
"application_id": "rust-app",
"name": "rust-actor",
"version": "1.0.0",
"wasm_module": {
"name": "rust-actor",
"version": "1.0.0",
"module_bytes": "'$(base64 -i rust_actor.wasm)'"
}
}' \
localhost:8000 \
plexspaces.application.v1.ApplicationService/DeployApplication- Default: 4MB (gRPC default)
- PlexSpaces Setting: 5MB (configured for flexibility)
- Use Case: Small WASM files, metadata-only deployments
- Max WASM File Size: 100MB (enforced by server)
- Use Case: Large WASM files (Python, unoptimized builds)
- Files <5MB: CLI automatically uses gRPC (faster, simpler)
- Files 5-100MB: CLI automatically uses HTTP multipart (seamless)
- Files >100MB: Optimize with
wasm-optfirst, then deploy (CLI will use HTTP)
CLI Behavior:
- Automatically detects file size
- Uses gRPC for files ≤5MB
- Automatically switches to HTTP for files >5MB and ≤100MB
- Returns error for files >100MB (with suggestion to optimize)
# Install
brew install binaryen # macOS
apt-get install binaryen # Linux
# Optimize WASM file
wasm-opt -Oz --strip-debug calculator_actor.wasm -o calculator_actor_opt.wasm
# Check size reduction
ls -lh calculator_actor*.wasmExpected Results:
- Python WASM: 39MB → 25-30MB (20-30% reduction)
- Rust WASM: 1MB → 500KB (50% reduction)
- JavaScript WASM: 2MB → 1MB (50% reduction)
The build script (build_python_actors.sh) automatically optimizes WASM files if wasm-opt is available:
./examples/simple/wasm_calculator/scripts/build_python_actors.sh
# Automatically runs wasm-opt if availableFor production deployments, consider language choice:
| Language | WASM Size | Build Time | Runtime Performance | Use Case |
|---|---|---|---|---|
| Rust | 100KB-1MB | Medium | Excellent | Production, performance-critical |
| Go | 2-5MB | Fast | Good | Good balance, fast iteration |
| JavaScript | 500KB-2MB | Fast | Good | Web integration, rapid prototyping |
| Python | 30-40MB | Medium | Moderate | ML, data processing, rapid prototyping |
PlexSpaces uses the WASM Component Model (latest and recommended): WIT-based components, wasmtime with component-model support, and polyglot actors (Python, Rust, TypeScript, Go). This section summarizes performance characteristics and how to get the most out of the system for highly performant deployments.
| Path | Cost per message | When to use |
|---|---|---|
| Component model (SimpleActor, PlexspacesActor — Python, WIT) | Per-message re-instantiation (new Store + instance per handle). Component is not recompiled; instantiation + init per message. | Polyglot (Python, etc.), WIT interfaces, latest tooling. Recommended for most apps. |
| Traditional WASM modules (non-component) | One instantiation per actor lifetime; same Store/instance reused. | Maximum throughput per actor, hot paths, Rust/Go JS without WIT. |
The runtime replaces component state after each successful handle() for component-model actors to avoid wasmtime’s “cannot enter component instance” trap on the second call. That keeps behavior correct and allows multiple sequential messages per actor.
- Per-actor locking: One lock per WASM instance (one message at a time per actor). This is the normal actor model.
- Across actors: Different actors use different instances and locks. Many actors can handle messages concurrently; there is no global lock.
- Horizontal scaling: Adding more actors increases parallelism. Re-instantiation is per-actor, so scaling out (more actors, more nodes) scales well.
- Vertical scaling (messages/sec per actor): For component-model actors, per-message re-instantiation is the main limit. For very high single-actor throughput, use traditional WASM modules or offload hot work to them.
-
Engine-level pooling (default: on)
- Enabled by default: The node creates the WASM runtime with
WasmRuntime::new(), which usesWasmConfig::default()whereenable_pooling = true. - This turns on wasmtime’s pooling allocator (
InstanceAllocationStrategy::Pooling): the engine reuses memory and instance allocations instead of allocating per instantiation. - You get this automatically when starting a node; no extra config is required.
- To turn it off (e.g. for debugging), create the runtime with
WasmRuntime::with_config(config)and setconfig.enable_pooling = false.
- Enabled by default: The node creates the WASM runtime with
-
Instance pooling (recommended, default: on)
- Recommendation: Yes. Instance pooling (pre-instantiated instances you checkout instead of instantiating each time) reduces spawn latency when many actors of the same module are created.
- Config:
use_instance_poolinWasmConfig(and in proto). On by default (true). When true, the runtime may use a per-moduleInstancePoolto serve instantiate requests (checkout from pool instead of full instantiation). - Current status: Deploy-path integration is planned. Until then, only engine-level pooling (above) is active when creating actors via HTTP/deploy; each actor is still created via
runtime.instantiate(). TheInstancePooltype exists inplexspaces-wasm-runtimeand can be used in custom code (e.g. high-spawn-rate workers). When deploy-path integration is complete,use_instance_pool = truewill enable checkout-from-pool for spawns. - To turn instance pooling off: set
config.use_instance_pool = false(or the proto field when using gRPC/config).
-
Keep durability off unless needed
durability_enabledis off by default inWasmConfig(and in proto).- When off, no checkpoint load on init or save on terminate — no extra I/O or serialization.
- Turn on only when actor state must survive restarts (e.g. Durable Objects–style apps).
-
Scale horizontally
- Run more actors and/or more nodes to increase throughput.
- Component-model cost is per-actor; spreading load across actors avoids a single-actor bottleneck.
-
Use
wasm-opt- Smaller modules load and instantiate faster.
- See Optimization Recommendations (e.g.
wasm-opt -Oz --strip-debug).
-
Prefer smaller, focused actors
- Many small actors can outperform fewer “heavy” actors by better utilizing concurrency and pooling.
-
Hot path: traditional WASM
- For a few actors that must handle very high message rates, use traditional WASM modules (non-component) so the same Store/instance is reused and there is no per-message re-instantiation.
-
Resource limits
- Set
limitsinWasmConfig(e.g.max_memory_bytes,max_fuel) to avoid runaway usage; tighter limits can also improve predictability. - Fuel limits: Default is 10 billion units (~1 second CPU time). For operations requiring heavy JSON serialization or complex computations, increase
max_fuel(e.g.,u64::MAX / 2for very large operations). Fuel is consumed during execution (ops, memory access, calls). Zero = unlimited (not recommended for untrusted code).
- Set
- Performance: Component model is correct and recommended; it pays a per-message instantiation cost. Traditional modules give the highest per-actor throughput.
- Concurrency: Good — per-actor serialization, no global serialization; many actors run in parallel.
- Scalability: Good horizontally (more actors/nodes); per-actor throughput is the main limit for component-model.
- For high performance: Use pooling, keep durability off by default, scale out with more actors, optimize with
wasm-opt, and use traditional WASM on the hottest paths if needed.
Python:
cd examples/simple/wasm_calculator
./scripts/build_python_actors.sh
# Output: wasm-modules/calculator_actor.wasmRust:
cd examples/rust/embedded/nbody_wasm/wasm-actors
cargo build --target wasm32-wasip2 --release
# Output: target/wasm32-wasip2/release/*.wasmTypeScript (actor-world WIT):
cd examples/typescript/apps/bank_account
./scripts/build.sh # tsc → esbuild bundle → jco componentize --disable all
# Output: account_actor.wasm# Start an empty node using CLI
cargo run --release --bin plexspaces -- start \
--node-id test-node \
--listen-addr 0.0.0.0:8000Note:
- gRPC and HTTP share a single port; dashboard available at
http://localhost:8000/ - You can check dashboard stats before deployment (should show 0 applications)
Verify Node is Running:
# Check dashboard summary
curl http://localhost:8000/api/v1/dashboard/summary | jq '.total_applications'
# Should return: 0ApplicationSpec Creation: The HTTP handler automatically creates an ApplicationSpec from form fields when deploying WASM applications. This follows the Erlang-style application model where applications are the unit of deployment.
How ApplicationSpec is Created:
- If
configfield is provided (TOML), it's parsed into ApplicationSpec - If
configis not provided, ApplicationSpec is auto-generated from form fields:name: Fromnameform field → Used as application identifier in ApplicationManager (important for undeployment)version: Fromversionform fieldtype:ApplicationTypeActive(active application with processes)description: Auto-generated as"WASM application: {name}"dependencies: Empty arrayenv: Empty map (can be set via config TOML)supervisor: None (can be set via config TOML)
ApplicationSpec Usage:
- The ApplicationSpec is passed to
WasmApplication::new()which implements theApplicationtrait - Used for supervisor tree initialization (if specified)
- Used for environment variables (if specified)
- Follows the same pattern as the
wasm-calculatorexample
Deployment via HTTP (Recommended for Large Files >5MB):
# Deploy with auto-generated ApplicationSpec
curl -v -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=calculator-app" \
-F "name=calculator" \
-F "version=1.0.0" \
-F "wasm_file=@wasm-modules/calculator_actor.wasm"
# Deploy with custom ApplicationSpec (via config TOML)
curl -v -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=calculator-app" \
-F "name=calculator" \
-F "version=1.0.0" \
-F "wasm_file=@wasm-modules/calculator_actor.wasm" \
-F "config=@app-config.toml"Deployment via CLI (For Small Files <5MB):
cargo run --release --bin plexspaces -- application deploy \
--node localhost:8000 \
--app-id rust-app \
--name rust-actor \
--version 1.0.0 \
--wasm target/wasm32-wasip2/release/rust_actor.wasm- Application Name vs Application ID: The
namefield is used byApplicationManagerfor storage and lookup. Use theapplication_id(notname) when undeploying — it is the unique key used by the HTTP DELETE endpoint andApplicationManager. - WASM Components (Python, TypeScript): ✅ Fully Supported - Components built with
componentize-py(Python) orjco componentize(TypeScript) use theactor-worldWIT interface fromwit/plexspaces-actor- Uses protobuf bytes plus typed WIT results for actor-world payloads and errors
- TypeScript: build with
jco componentize ... --disable allso the component only importsplexspaces:actor/host(no WASI) - See
examples/python/andexamples/typescript/apps/bank_account/for working examples - See
wit/plexspaces-actor/for the WIT interface
- Traditional WASM Modules (Rust, Go): ✅ Supported - Use standard actor interface
- ApplicationSpec is Required: All WASM deployments must include an ApplicationSpec (auto-generated or provided). This ensures applications follow the Erlang-style application model.
Testing WASM Deployment:
- The integration test (
cargo test --package plexspaces-node --test http_wasm_deployment) creates a working traditional WASM module and successfully deploys it
PlexSpaces automatically deploys WASM applications from the wasm_apps_directory on node startup. This enables Tomcat-style auto-deployment where applications persist across restarts.
File Structure:
{wasm_apps_directory}/
payment-handler/
app.wasm # Required: WASM module
application-spec.toml # Optional: ApplicationSpec config
calculator/
app.wasm
application-spec.toml
Configuration:
- Environment variable:
PLEXSPACES_WASM_APPS_DIR(default:${base_dir}/apps) - Config file:
runtime.wasm_apps_directoryinrelease.yaml
How It Works:
- Node scans
wasm_apps_directoryon startup - Finds all subdirectories containing
app.wasmfiles - Automatically deploys each valid WASM application
- Errors are logged but don't prevent node startup
When deploying via HTTP/gRPC API, you can optionally save WASM files to disk for persistence.
Configuration:
- Environment variable:
PLEXSPACES_SAVE_WASM_APPS=1(default: disabled) - Config file:
runtime.save_wasm_apps: trueinrelease.yaml
Important:
⚠️ Only saves during API deployments (HTTP/gRPC) - NOT during auto-deploy⚠️ Disabled by default - only enable for testing/development⚠️ Production: Use proper deployment pipelines- Files are saved atomically to prevent corruption
- Format:
{wasm_apps_directory}/{app-name}/app.wasmand{wasm_apps_directory}/{app-name}/application-spec.toml - If
namespaceis omitted fromapplication-spec.toml, WASM deployment derives it from the app name so actor IDs and app isolation remain stable across deploy and restart.
Example Workflow:
# 1. Enable saving (testing only)
export PLEXSPACES_SAVE_WASM_APPS=1
# 2. Deploy via API - files are saved to apps/payment-handler/app.wasm and application-spec.toml
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=payment-handler" \
-F "name=payment-handler" \
-F "version=1.0.0" \
-F "wasm_file=@payment-handler.wasm"
# 3. On next restart, payment-handler is auto-deployed automatically from the subdirectorySee Installation Guide for complete details.
- Use the test script (
./scripts/test-empty-node-deployment.sh) which automatically creates a working WASM module - For manual testing, use Rust/Go WASM modules or TypeScript/Python components (actor-world WIT)
Check Dashboard Stats:
# Check dashboard summary (should show 1 application now)
curl http://localhost:8000/api/v1/dashboard/summary | jq '.total_applications'
# Should return: 1List applications (gRPC; gRPC and HTTP share the same port, e.g. 8000):
cargo run --bin plexspaces -- list --node localhost:8000 --jsonUse the same ListApplications RPC with grpcurl or other gRPC clients if you are not using the CLI.
View Dashboard:
# Open in browser
open http://localhost:8000/
# Or
http://localhost:8000/dashboard/node/test-nodeComplete Dashboard Workflow:
- Start empty node → Check dashboard (0 applications)
- Deploy WASM application → Check dashboard (1 application)
- Undeploy application → Check dashboard (0 applications)
See DEPLOY_EMPTY_NODE_GUIDE.md for the complete workflow.
Important: Use application_id for undeployment and dashboard correlation.
HTTP:
# Use application_id
curl -X DELETE http://localhost:8000/api/v1/applications/calculator-appCLI:
cargo run --release --bin plexspaces -- application undeploy \
--node localhost:8000 \
--name calculatorVerify Undeployment:
# Check dashboard (should show 0 applications again)
curl http://localhost:8000/api/v1/dashboard/summary | jq '.total_applications'
# Should return: 0Problem: WASM file exceeds gRPC 5MB limit
Solution:
- CLI: Automatically handles this - if you see this error, the CLI should have automatically switched to HTTP. Check CLI version.
- Manual: Use HTTP multipart upload:
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "wasm_file=@large_file.wasm" \
...Problem: HTTP multipart parsing fails when uploading WASM files
Solution:
- Server Configuration: The server is configured with a 100MB body size limit via
DefaultBodyLimitmiddleware. If you see this error:- Verify the file size is ≤100MB
- Check that the
Content-Typeheader ismultipart/form-data - Ensure all required fields are present (
application_id,name,version,wasm_file) - Check server logs for detailed error messages
Example with proper curl syntax:
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=calculator-app" \
-F "name=calculator" \
-F "version=1.0.0" \
-F "wasm_file=@examples/simple/wasm_calculator/wasm-modules/calculator_actor.wasm"Problem: WASM file exceeds HTTP 100MB limit
Solutions:
- Use
wasm-optto reduce size - Consider Rust/Go for smaller files
- Split application into multiple smaller modules (future)
Problem: Python WASM components crash during initialization with:
error while executing at wasm backtrace:
...
libpython3.12.so!PyObject_SetItem
libcomponentize_py_runtime.so!set_item::inner
Root Cause: componentize-py's pyo3 runtime tries to call os.putenv() during Python initialization. WASI doesn't support runtime environment variable modification, causing the crash.
Solution: The PlexSpaces runtime is configured to NOT inherit environment variables. Instead, it explicitly sets only the minimal env vars Python needs:
// In crates/wasm-runtime/src/instance.rs
let wasi_ctx = wasmtime_wasi::WasiCtxBuilder::new()
.inherit_stdio()
// Don't use inherit_env() - causes PyObject_SetItem errors
.env("PYTHONDONTWRITEBYTECODE", "1")
.env("PYTHONUNBUFFERED", "1")
.env("HOME", "/")
.env("PATH", "/")
.build();This fix is already applied in the codebase. If you're running an older version, update to the latest.
Problem: Python WASM actors crash with memory deallocation errors:
error while executing at wasm backtrace:
0: libpython3.14.so!tuple_dealloc
1: libpython3.14.so!_Py_Dealloc
...
Error Types:
| Error | Symptom | Cause |
|---|---|---|
match_dealloc |
Crash when using pattern matching or hashlib | hashlib.md5() and similar functions |
tuple_dealloc |
Crash when returning from functions | Complex return values, json.dumps() |
func_dealloc |
Crash during function cleanup | Helper function calls |
Root Cause: The Python 3.14 runtime in componentize-py has memory management bugs in the WASM environment.
Workarounds:
# ❌ CRASHES - hashlib causes match_dealloc
import hashlib
h = hashlib.md5((flag + user).encode()).hexdigest()
# ✅ WORKS - simple inline hash
h = 0
for c in (flag + user):
h = (h + ord(c)) % 100
# ❌ MAY CRASH - json.dumps with complex nested data
return json.dumps({"status": "ok", "data": {"nested": "value"}})
# ✅ WORKS - string literal for simple responses
return '{"status":"ok"}'
# ❌ MAY CRASH - helper function call
def my_hash(s):
return sum(ord(c) for c in s) % 100
h = my_hash(flag + user)
# ✅ WORKS - inline the logic
h = 0
for c in (flag + user):
h = (h + ord(c)) % 100Best Practices for Stable Python WASM Actors:
- Avoid hashlib entirely - Use simple arithmetic hash functions
- Use string literals for simple JSON -
'{"ok":true}'instead ofjson.dumps({"ok": True}) - Inline calculations - Don't extract logic into helper functions
- Flat control flow - Avoid nested try-except blocks
- Simple return values - Keep data structures flat and simple
Reference: See examples/python/apps/feature_flags/ for a working example with all workarounds applied.
Problem: Python WASM files are 30-40MB
Solutions:
- Use
wasm-optto reduce size (20-30% reduction) - Consider Rust/Go for smaller files
- Use HTTP multipart (supports up to 100MB)
Check:
- Node is running:
curl http://localhost:8000/healthorcurl http://localhost:8000/health - WASM file is valid:
wasm-validate calculator_actor.wasm(if wasm-validate is installed) - WIT interface matches: Check
wit/plexspaces-actor/actor.wit - HTTP gateway is running: Check logs for "Starting HTTP gateway server on http://..."
Problem: Cannot connect to HTTP endpoint
Check:
- gRPC and HTTP share a single port — check node logs for "Single-port gRPC+HTTP server ready"
- Verify the port in
--listen-addrmatches your curl commands - Verify firewall allows connections to HTTP port
# Create Python 3.12+ virtual environment
python3.12 -m venv ~/venv
source ~/venv/bin/activate
# Install componentize-py
pip install componentize-pyPython components use the actor-world WIT world located at wit/plexspaces-actor/world.wit.
This interface uses protobuf wire bytes for complex request/response bodies and WIT result
for failures. SDKs generate the protobuf models and hide the raw byte handling from app code:
package plexspaces:actor@0.1.0;
interface actor {
init: func(config: payload) -> result<_, actor-error>;
handle: func(from-actor: string, msg-type: string, payload: payload) -> result<payload, actor-error>;
get-state: func() -> result<payload, actor-error>;
set-state: func(state: payload) -> result<_, actor-error>;
}
// The host API is organized into 9 namespaced interfaces.
// Key interfaces shown here; see wit/plexspaces-actor/ for the complete surface.
interface host-actor {
send: func(to: string, msg-type: string, payload: payload) -> result<_, actor-error>;
ask: func(to: string, msg-type: string, payload: payload, timeout-ms: u64) -> result<payload, actor-error>;
self-id: func() -> string;
spawn: func(module-ref: string, actor-id: string, init-config: payload) -> result<string, actor-error>;
stop: func(actor-id: string) -> result<_, actor-error>;
send-after: func(delay-ms: u64, msg-type: string, payload: payload) -> result<string, actor-error>;
// + link, unlink, monitor, demonitor, pg-join, pg-leave, pg-members, pg-broadcast
}
interface host-logging {
log: func(level: string, message: string);
now-ms: func() -> u64;
}
interface host-kv {
kv-get: func(key: string) -> result<payload, actor-error>;
kv-put: func(key: string, value: payload) -> result<_, actor-error>;
kv-delete: func(key: string) -> result<_, actor-error>;
kv-list: func(prefix: string) -> result<list<string>, actor-error>;
kv-put-with-ttl: func(key: string, value: payload, ttl-seconds: u64) -> result<_, actor-error>;
kv-get-ttl: func(key: string) -> result<u64, actor-error>;
kv-cas: func(key: string, expected: payload, new-value: payload) -> result<bool, actor-error>;
kv-increment: func(key: string, delta: s64) -> result<s64, actor-error>;
kv-multi-get: func(keys-json: payload) -> result<payload, actor-error>;
kv-multi-put: func(entries-json: payload) -> result<_, actor-error>;
// Alarm functions are co-located in host-kv for implementation convenience.
// Use host.alarm / host.Alarm() in SDK code for the clean alarm namespace API.
alarm-set: func(timestamp-ms: u64) -> result<_, actor-error>;
alarm-get: func() -> result<u64, actor-error>;
alarm-delete: func() -> result<_, actor-error>;
}
interface host-ts {
ts-write: func(request: payload) -> result<_, actor-error>;
ts-read: func(request: payload) -> result<payload, actor-error>;
ts-take: func(request: payload) -> result<payload, actor-error>;
ts-read-all: func(request: payload) -> result<payload, actor-error>;
}
// Additional namespaced interfaces: host-locks, host-blob, host-pool, host-shard, host-http
// See wit/plexspaces-actor/ for complete definitions.
world actor-world {
import host-actor;
import host-logging;
import host-kv;
import host-ts;
import host-locks;
import host-blob;
import host-pool;
import host-shard;
import host-http;
export actor;
}WASM actors using the actor-world WIT call host.ts.write (Python/TypeScript) or host.TS().Write (Go) with protobuf WriteRequest bytes and read/take using protobuf ReadRequest bytes. The runtime decodes those bytes once and delegates to the same TupleSpace backend as native code. Use this for event streams, audit logs, or coordination without keyvalue.
When to use host.ts.write: Prefer tuplespace writes for fire-and-forget event or audit streams when WASM integration is stable; it avoids reentrancy and readonly issues that can occur when WASM calls into the keyvalue backend during message handling.
Deprecated flat name: host.ts_write is the old flat API name; use host.ts.write / host.TS().Write instead.
WASM actors using the actor-world WIT can use the elastic pool API to checkout workers from a named pool, send them work, and check them in when done. When the pool is not configured (or checkout fails), application code can fall back to process group broadcast.
| WIT function | Description |
|---|---|
pool-checkout(pool-name, timeout-ms) |
Returns protobuf plexspaces.pool.v1.ActorHandle on success or typed actor-error on failure/timeout. |
pool-checkin(pool-name, actor-id, checkout-id, healthy) |
Returns success/error result. |
pool-get-metrics(pool-name) |
Returns protobuf plexspaces.pool.v1.PoolMetrics. |
SDK usage: Python host.pool_checkout / host.pool_checkin / host.pool_get_metrics; Go host.PoolCheckout / host.PoolCheckin / host.PoolGetMetrics; TypeScript host.poolCheckout / host.poolCheckin / host.poolGetMetrics. See Parameter sweep (migrating_merlin) (Python, Go, TypeScript, Rust) for a full example combining pool, tuple space (work queue), and process group fallback.
Deployable WASM apps can also use the framework shard-group APIs through the actor-world host. This keeps leader-worker apps on the same core ActorService path used by native Rust.
| WIT function | Description |
|---|---|
create-shard-group(request) |
Uses protobuf CreateShardGroupRequest / CreateShardGroupResponse. |
bulk-update-shard-group(request) |
Uses protobuf BulkUpdateShardGroupRequest / BulkUpdateShardGroupResponse. |
map-shard-group(request) |
Uses protobuf MapShardGroupRequest / MapShardGroupResponse. |
scatter-gather(request) |
Uses protobuf ScatterGatherRequest / ScatterGatherResponse. |
Use this for WASM leader-worker applications that need framework-owned scatter/gather without dropping down to gRPC or hand-written host bindings. See Heat Diffusion for a Rust WASM example that deploys to multiple nodes and drives workers through the host shard-group surface.
WASM actors can register, discover, and look up services through the object registry host surface. All registry WIT functions use protobuf wire-encoded payloads (list<u8>) so the data model stays proto-first across the WASM boundary. Message definitions live in proto/plexspaces/v1/registry/object_registry.proto.
| WIT function | Proto request type | Proto response type | Description |
|---|---|---|---|
register(request) |
plexspaces.object_registry.v1.RegisterRequest |
(unit) | Register this actor in the service registry. |
unregister(request) |
plexspaces.object_registry.v1.UnregisterRequest |
(unit) | Remove a registration. |
lookup(request) |
plexspaces.object_registry.v1.LookupRequest |
plexspaces.object_registry.v1.LookupResponse |
Look up a single object by id and type. |
lookup-by-alias(alias) |
(string) | plexspaces.object_registry.v1.LookupResponse |
Resolve an alias string to a registered object. |
discover(request) |
plexspaces.object_registry.v1.DiscoverRequest |
plexspaces.object_registry.v1.DiscoverResponse |
Filter-based discovery of multiple objects. |
heartbeat(request) |
plexspaces.object_registry.v1.HeartbeatRequest |
(unit) | Refresh the liveness timestamp of a registration. |
Tenant isolation is enforced at the host layer — the actor cannot forge a different tenant:
tenant_idin the host context is always injected from the deployment (from the JWT at gRPC deploy time, or fromtenant_idinapp-config.tomlfor file-copy deploys). The guest-suppliedtenant_idfield inside the proto payload is always ignored.namespacemay be supplied by the guest; if empty the host falls back to the application's default namespace.
SDK usage (SDKs handle proto encoding/decoding):
# Python
host.registry_register({"object_id": actor_id, "object_type": "actor", "object_category": "worker"})
result = host.registry_discover({"object_type": "actor", "object_category": "worker"})// Go
host.Registry().Register(plexspaces.ObjectRegistration{
ObjectID: cfg.ActorID, ObjectType: "actor", ObjectCategory: "worker",
})
regs, _ := host.Registry().Discover(plexspaces.DiscoverOptions{
ObjectType: plexspaces.ObjectType.ACTOR,
})// TypeScript
host.registry.register({ objectId: actorId, objectType: "actor", objectCategory: "worker" });
const regs = host.registry.discover({ objectType: RegistryObjectType.ACTOR });For file-copy / embedded deploys — set tenant_id in app-config.toml to enable tenant isolation:
name = "my-app"
version = "1.0.0"
tenant_id = "acme" # required for multi-tenant nodes
namespace = "production"If tenant_id is omitted the node logs a warning and all registry calls use an empty tenant scope, which effectively disables isolation. See Auto-Deploy and Persistence for the full file-copy deploy workflow.
WASM actors using actor-world can persist data via the host keyvalue API. This avoids
in-actor state serialization issues and provides reliable storage across the WASM boundary.
Choosing storage: For event streams or audit logs, prefer tuplespace writes with protobuf WriteRequest payloads. Key-value values are actor-world bytes, so SDKs typically persist protobuf messages or application-owned binary payloads without JSON adapters.
SDK namespace accessors: Python host.kv.*, Go host.KV().*, TypeScript host.kv.*.
| WIT function (host-kv interface) | SDK — Python | SDK — Go | SDK — TypeScript | Description |
|---|---|---|---|---|
kv-get(key) |
host.kv.get(key) |
host.KV().Get(key) |
host.kv.get(key) |
Returns raw value bytes on success, or a typed actor error. |
kv-put(key, value) |
host.kv.put(key, value) |
host.KV().Put(key, value) |
host.kv.put(key, value) |
Stores raw value bytes. Returns success/error as a WIT result. |
kv-put-with-ttl(key, value, ttl_seconds) |
host.kv.put_with_ttl(k,v,ttl) |
host.KV().PutWithTTL(k,v,ttl) |
host.kv.putWithTtl(k,v,ttl) |
Like kv-put but the key expires after ttl_seconds. |
kv-get-ttl(key) |
host.kv.get_ttl(key) |
host.KV().GetTTL(key) |
host.kv.getTtl(key) |
Returns remaining TTL in seconds, or 0 if no TTL / key not found. |
kv-delete(key) |
host.kv.delete(key) |
host.KV().Delete(key) |
host.kv.delete(key) |
Removes a key (idempotent). |
kv-list(prefix) |
host.kv.list(prefix) |
host.KV().List(prefix) |
host.kv.list(prefix) |
Returns all keys matching prefix. |
kv-cas(key, expected, new_value) |
host.kv.cas(key,exp,new) |
host.KV().CAS(key,exp,new) |
host.kv.cas(key,exp,new) |
Compare-and-swap: sets value only if current matches expected. Returns bool. Pass empty bytes for expected when key must not exist. |
kv-increment(key, delta) |
host.kv.increment(key,n) |
host.KV().Increment(key,n) |
host.kv.increment(key,n) |
Atomically increments a numeric key by delta (creates key at delta if absent). Returns new value. |
kv-multi-get(keys_json) |
host.kv.multi_get(keys) |
host.KV().MultiGet(keys) |
host.kv.multiGet(keys) |
Fetches multiple keys. Accepts JSON-encoded [string] array; returns JSON array of base64-encoded values or null for missing keys. |
kv-multi-put(entries_json) |
host.kv.multi_put(entries) |
host.KV().MultiPut(entries) |
host.kv.multiPut(entries) |
Stores multiple key-value pairs. Accepts JSON object mapping key → base64-encoded value string. |
Scope: Keys are scoped per actor (namespace derived from actor ID). The node provides an in-memory keyvalue store for WASM actors by default.
kv-multi-get / kv-multi-put encoding note: Because TinyGo cannot pass WIT list<string> as host-import inputs, both batch functions use payload (list) at the WIT boundary. The caller JSON-encodes the key list / entry map, and binary values are base64-encoded within the JSON.
Deprecated flat API: The old flat names (host.kv_get, host.kv_put, host.KVGet, host.KVPut, host.kvGet, etc.) remain for backward compatibility but are deprecated. Use the host.kv / host.KV() namespace accessor instead.
Example (Python SDK):
from plexspaces import actor, handler, host
@actor
class SensorStream:
@handler("ingest")
def ingest(self, sensor_id: str = "", value: str = "0") -> dict:
raw = host.kv.get("readings")
data = ReadingList().from_bytes(raw) if raw else ReadingList()
data.items.append(Reading(sensor_id=sensor_id, value=value))
host.kv.put("readings", data.to_bytes())
return {"reading_count": len(data.items)}
@handler("count")
def count(self) -> dict:
raw = host.kv.get("readings")
data = ReadingList().from_bytes(raw) if raw else ReadingList()
return {"reading_count": len(data.items)}Best practice: Have handlers return protobuf-backed models or SDK-native values and let the SDK decorator own actor-world serialization. Keep business logic in handlers and keep the WIT boundary thin.
WASM actors can schedule a single durable alarm per actor instance — equivalent to Cloudflare Durable Objects state.storage.setAlarm(timestamp). The alarm is backed by ReminderFacet and survives actor deactivation.
| Operation | WIT Function | Go | Python | TypeScript |
|---|---|---|---|---|
| Set alarm | alarm-set |
host.Alarm().Set(tsMs) |
host.alarm.set(ts_ms) |
host.alarm.set(tsMs) |
| Get alarm | alarm-get |
host.Alarm().Get() |
host.alarm.get() |
host.alarm.get() |
| Delete alarm | alarm-delete |
host.Alarm().Delete() |
host.alarm.delete() |
host.alarm.delete() |
Descriptions:
alarm-set(timestamp_ms): Schedule the alarm to fire attimestamp_ms(Unix milliseconds). Replaces any existing alarm for this actor.alarm-get(): Returns the scheduled alarm timestamp in ms, or 0 if no alarm is set.alarm-delete(): Cancels the pending alarm (idempotent).
When the alarm fires, the runtime delivers a "__alarm__" message to the actor's handle() function. Actors handle it like any other message type:
# Python SDK
@handler("__alarm__")
def on_alarm(self) -> dict:
# process batch, flush buffer, etc.
return {}// TypeScript SDK
@handler("__alarm__")
async onAlarm(payload: any) {
// process batch
return {};
}// Go SDK — switch on message type in your handler
case "__alarm__":
// process batchReplacing an alarm: Calling alarm-set while an alarm is already pending atomically replaces it with the new timestamp. The old pending delivery is cancelled.
Cloudflare equivalence: alarm-set(Date.now() + 10_000) + case "__alarm__" maps directly to Cloudflare DO state.storage.setAlarm(Date.now() + 10_000) + async alarm() { ... }.
cd examples/python/apps/calculator
./build.shThe build script:
- Generates Python bindings from WIT
- Compiles Python to WASM Component using componentize-py
- Produces a ~35MB WASM file (includes Python runtime)
from plexspaces import actor, handler, state
from generated.calculator_pb2 import AddRequest, AddResponse
@actor
class Calculator:
invocation_count: int = state(default=0)
@handler("add")
def add(self, request: AddRequest) -> AddResponse:
self.invocation_count += 1
return AddResponse(result=sum(request.operands))See examples/python/README.md for complete documentation.
TypeScript actors use the same actor-world WIT world as Python. Use the
TypeScript SDK: extend PlexSpacesActor<TState>, implement
getDefaultState() and on<Op>(payload) handlers, then build with jco componentize
(not Javy).
SDK Simplification: The SDK automatically generates WIT TypeScript types during build - client code doesn't need to run jco types or import generated files. The SDK abstracts all WIT details away, keeping client code simple.
Build (from examples/typescript/apps/bank_account):
- Install deps:
npm install(includes@plexspaces/sdk,esbuild,jco) - Build:
./scripts/build.sh— runs tsc, esbuild bundle (actor + SDK → single ESM), thenjco componentize account_actor_bundle.mjs --wit wit/plexspaces-actor -o account_actor.wasm --disable all
Important: Use --disable all so the component only imports plexspaces:actor/host; the PlexSpaces runtime does not provide WASI 0.2.3 that jco would otherwise add.
SDK Simplification:
- WIT TypeScript types are automatically generated by the SDK during build (
npm run buildin SDK) - Client code doesn't need to run
jco typesor import generated files - SDK uses iterative JSON serialization to avoid WASM recursion issues
- Just extend
PlexSpacesActorand implement handlers - SDK handles all WIT details
See examples/typescript/apps/bank_account/README.md and sdks/typescript/README.md for full docs.
WASM actors support checkpoint-based durability via the get-state() and set-state() WIT interface functions. This follows the Cloudflare Durable Objects pattern.
- Actor manages state internally: Your WASM actor maintains state in memory
- Framework calls
get-state(): On shutdown or checkpoint interval, framework gets state - State is persisted: Framework stores state snapshot in SQLite/PostgreSQL
- On restart,
set-state()is called: Framework restores state from checkpoint
from generated.state_pb2 import ActorState
class StatefulActor:
def __init__(self):
self.data = ActorState()
def get_state(self) -> bytes:
"""Called by framework to checkpoint state."""
return self.data.SerializeToString()
def set_state(self, state_bytes: bytes) -> None:
"""Called by framework to restore state on restart."""
if state_bytes:
self.data.ParseFromString(state_bytes)- Proto-first state: State snapshots are raw bytes, typically protobuf messages defined alongside the actor
- Typed errors:
set-state()andget-state()use actor-worldresult<_, actor-error> - Graceful degradation:
set-state()should handle empty input - Size matters: Keep state small for fast checkpointing
Float values in actor state are automatically sanitized for WASM safety. The runtime detects special IEEE 754 float values (NaN, Infinity, -Infinity) that are not valid in JSON and replaces them with safe defaults before serialization. On deserialization, these values are restored transparently.
This means actors can use float arithmetic freely (including operations that produce NaN or infinity) without worrying about state serialization failures. The sanitization and restoration process is fully transparent to the actor -- no special handling is required in actor code.
WASM actors can communicate with each other using the ask pattern (request-reply), following the same semantics as Erlang's gen_server:call/2. The Python SDK exposes this via host.ask().
How it works:
- Caller actor invokes
host.ask(target_id, msg_type, payload, timeout_ms) - The SDK serializes the payload and calls the WIT
host.askfunction - The Rust runtime creates a temporary sender actor with a canonical temporary-sender
ActorIdand aReplyWaiter - A request message is created with
id = req-{ULID}and routed to the target actor - Target actor's
handle()method processes the message and returns a result - The runtime wraps the result in a reply message with
id = res-{ULID}and sends it back to the temporary sender - The
ReplyWaiterreceives the reply and returns it to the caller
Message ID conventions:
- Request messages:
req-{ULID}(e.g.,req-01JMXYZ...) - Reply messages:
res-{ULID}(e.g.,res-01JMXYZ...) - These prefixes enable tracing request/reply flows in logs
Example (Python):
from plexspaces import actor, handler, host, state
@actor
class Coordinator:
@handler("run")
def run(self) -> dict:
# Ask a worker to compute something (request-reply)
result = host.ask("worker-0//worker::my-app@node-1", "compute", {"x": 42}, timeout_ms=5000)
return {"status": "ok", "worker_result": result}
@actor
class Worker:
@handler("compute")
def compute(self, x: int = 0) -> dict:
return {"result": x * 2}Debugging ask flow: Enable debug logging to trace the full message flow:
RUST_LOG=plexspaces_application=debug,plexspaces_actor::actor_registry=debug
This will show:
WASM ask: sending request via ActorRef message_id=req-01JMX... sender_id=coordinator//worker::app@node recipient_id=worker-0//worker::app@node
registry ask: routing request to target message_id=req-01JMX... sender_id=ask_CORR//temp_sender::app@node correlation_id=CORR
WasmActor handle_message: sending reply request_id=req-01JMX... reply_id=res-01JMX... reply_to=ask_CORR//temp_sender::app@node
registry ask: reply received request_id=req-01JMX... reply_id=res-01JMX...
WASM ask: reply received request_id=req-01JMX... reply_id=res-01JMX...
Important: The Python SDK's handle() return path uses json.dumps() directly, which serializes Python floats as JSON numbers. This ensures that inter-actor host.ask() responses preserve numeric types correctly.
Float sanitization (converting floats to strings) is only applied in the get_state() path for checkpoint persistence safety. The set_state() path reverses this with _desanitize_from_wasm(). This sanitization is not applied to handle() responses to avoid corrupting numeric values in inter-actor communication.
If you encounter "unsupported operand type(s) for +=: 'float' and 'str'" errors in inter-actor ask responses, ensure your WASM module is built with the latest SDK (>= v0.2.0) where this fix is applied.
Component-model WASM actors are re-instantiated after each handle() call. This is required because wasmtime 16.x raises a "cannot enter component instance" trap when a component is called a second time (see wasmtime#8943).
How it works:
- Actor receives a message →
handle()is called on the WASM instance - After
handle()returns, the runtime callsget_state()to capture actor state - A new WASM instance is created from the same compiled module
set_state()restores the captured state on the new instance- The new instance is ready for the next message
Concurrency Control:
- Per-actor re-instantiation lock: Each
WasmInstancehas a semaphore (permit count 1) that serializes re-instantiations per actor. Both must be held where applicable: drop any component_state lock before acquiring the per-actor lock to avoid deadlock (seecrates/wasm-runtime/tests/simple_actor_deadlock.rs). - Global instantiation cap: When pooling is enabled,
WasmRuntimeholds a global semaphore used for both initial instantiation (virtual actor activation) and re-instantiation (afterhandle()). The permit count is set byWasmConfig.max_concurrent_instantiations(default: 7) to stay under Wasmtime’s per-memory-stripe limit (e.g. 10). This avoids the "maximum concurrent limit of 10 for memory stripe 0 reached" error. If that error appears, reduce load or increasemax_concurrent_instantiationsin config. - Sequential processing per actor: Only one re-instantiation at a time per actor; messages queue in the mailbox.
- Observability: Metrics track re-instantiation duration, errors, and queue depth (see
plexspaces_wasm_reinstantiation_*metrics).
State preservation: The get_state()/set_state() cycle preserves all state() fields. Fields not declared with state() are lost across re-instantiation. For large derived data (e.g., data shards), regenerate from deterministic seeds rather than persisting.
When will this change? The wasmtime project is working on component-model-async support which will allow re-entrant component calls. Once PlexSpaces upgrades to a wasmtime version with this feature, re-instantiation will no longer be needed and per-message performance will improve significantly.
Impact on performance: Re-instantiation adds per-message overhead (typically 1-5ms for Python actors). The per-actor lock ensures reliable operation without hitting Wasmtime's concurrent limits, even under high message rates. For high-throughput single-actor scenarios, consider using traditional (non-component) WASM modules.
State operations are fully instrumented with Prometheus metrics:
plexspaces_wasm_get_state_total: Total checkpoint callsplexspaces_wasm_set_state_total: Total state restore callsplexspaces_wasm_state_size_bytes: Size of persisted state
See Durability Documentation for complete details.
WASM actors are deployed under a supervisor tree that provides automatic restart on failure. This follows the Erlang/OTP supervision model.
When deploying a WASM application without explicit supervisor configuration, the server automatically creates a default supervisor:
Strategy: OneForOne (restart only the failed actor)
Max Restarts: 5
Children: The WASM actor as a permanent worker
The supervisor integration for WASM actors uses a unified approach:
- Factory Function Pattern: Each WASM actor has a factory function (
StartFn) that can recreate the actor build_wasm_actorHelper: Single entry point for building WASM actors with full service wiringSupervisor.add_child(): Adds WASM actors to the supervisor with proper ChildSpec- Automatic Restart: When an actor crashes, the supervisor calls the factory to recreate it
WasmApplication
└── Root Supervisor (one-for-one)
├── worker-1 (WASM actor) ← Factory can recreate on crash
└── worker-2 (WASM actor) ← Factory can recreate on crash
Applications with supervisor trees create actors whose IDs incorporate the child name, behavior actor_type, namespace, and node ID. The format is:
name//actor_type::namespace@node_id
For example, given an application with namespace my-app deployed to node-1 with child identity name = worker-1, actor_type = my_worker, the actor ID will be:
worker-1//my_worker::my-app@node-1
This format ensures that all actors within a supervisor tree are uniquely identifiable and properly scoped to their namespace, even when multiple applications share the same node.
Runtime plexspaces_actor::ChildSpec — created by the WASM layer with a factory, using the child’s resolved canonical identity:
// `child_actor_id` is the full canonical ActorId (from proto ActorIdentity + namespace + node).
// The supervisor process uses an opaque label via Supervisor::new(supervisor_label, ...) — not this ActorId.
let spec = ChildSpec::worker(child_actor_id, start_fn);build_wasm_actor() - Unified helper that:
- Wires up all services (TupleSpace, ObjectRegistry, JournalStorage, etc.)
- Creates unstarted Actor with proper context
- Returns (Actor, ActorRef) for supervisor management
Factory Function - Captured context for restart:
- node, proto_child_spec, module_hash, runtime
- Called by supervisor when actor needs restart
| Strategy | Behavior | Use Case |
|---|---|---|
| OneForOne | Restart only failed actor | Independent workers |
| OneForAll | Restart all actors | Tightly coupled actors |
| RestForOne | Restart failed + started after | Dependency chain |
# Deploy feature flags service (supervisor auto-created)
./target/debug/plexspaces deploy \
--node localhost:8090 \
-i feature-flags-test \
-n flags \
-w examples/python/apps/feature_flags/feature_flags_actor.wasmThe deployed application automatically has:
- OneForOne supervisor
- Automatic restart on crash (up to 5 times)
- Full service access (TupleSpace, ObjectRegistry, etc.)
To customize supervisor settings, provide a config TOML file:
# app-config.toml
name = "my-app"
version = "1.0.0"
namespace = "my-app" # Required: all actors scoped to this namespace
[supervisor]
strategy = "one_for_one"
max_restarts = 10
max_restart_window_seconds = 60
[[supervisor.children]]
name = "worker-1"
actor_type = "my_worker"
type = "worker"
restart = "permanent"
shutdown_timeout_seconds = 5name and actor_type are the declaration-time slice (ActorIdentity); the server combines them with deploy namespace and node_id into the canonical ActorId string (name//actor_type::namespace@node_id). Use a lowercase actor_type slug matching plexspaces.common.v1.ActorIdentity (same pattern as the actor_type field on ActorId).
Optional behavior_kind on the same child is OTP-style metadata only (for example GenServer, GenEvent, Workflow). It must not be reused as actor_type: the behavior class / WASM dispatch key and BehaviorRegistry factory key is always the actor_type slug.
Deploy with custom config:
curl -X POST http://localhost:8000/api/v1/applications/deploy \
-F "application_id=my-app" \
-F "name=my-app" \
-F "version=1.0.0" \
-F "wasm_file=@my_actor.wasm" \
-F "config=@app-config.toml"- Use HTTP for Large Files: Always use HTTP multipart for files >5MB
- Optimize Before Deploy: Run
wasm-opton all WASM files - Version Control: Tag WASM files with version numbers
- Content-Addressable: Use module hash for caching (automatic)
- Language Selection: Choose language based on size/performance requirements
- Test Locally First: Verify WASM file works before deploying to production
- Monitor Deployment: Check dashboard after deployment to verify application is running
Integration tests are available in crates/node/tests/http_wasm_deployment.rs:
# Run tests (requires WASM files to be built first)
cargo test --package plexspaces-node --test http_wasm_deployment
# Build WASM files first
cd examples/simple/wasm_calculator
./scripts/build_python_actors.shTests cover:
- HTTP multipart deployment
- HTTP undeployment
- Size limit enforcement (100MB)
- Error handling
- SDK Guide - Python and TypeScript SDKs for building WASM actors
- Polyglot WASM Development Guide - Polyglot development (Python, TypeScript, Rust, Go) with WIT abstractions
- Python WASM Examples - Python WASM actors with componentize-py
- TypeScript Bank Account Example - TypeScript WASM with jco and actor-world WIT
- WIT Specification
- componentize-py - Python to WASM Component compiler
- jco - JavaScript/TypeScript componentize (componentize-js)
- wasm-opt Documentation
- HTTP Multipart Upload Best Practices
- WASM Calculator Example