@di-framework/cli-plugin-platform
v6.2.0
Published
di-framework CLI extension that builds, serves, and deploys DI Framework apps as platform WebAssembly components.
Maintainers
Readme
@di-framework/cli-plugin-platform
di-framework CLI extension for targeting wasmCloud: build a DI Framework HTTP app into a WASI 0.3 WebAssembly component, serve it locally, and deploy it from a workspace manifest.
di-framework extensions install platform
di-framework platform build # bundle + jco componentize → dist/<name>.wasm
di-framework platform dev # build, then serve locally (wasmtime by default)
di-framework platform deploy # nearest project, default target
di-framework platform deploy greeter # named project anywhere in the workspace
di-framework platform deploy greeter --target development # a target other than default-target
di-framework platform destroy greeter
di-framework platform cluster init # generate deploy/platform + local target
di-framework platform cluster up --yes # start the generated platform (default-target)
di-framework platform cluster destroy --yes
di-framework platform doctor # project + toolchain readiness checksRun these commands directly. Do not wrap them in package.json scripts.
Project convention
A component project is marked by di-framework.config.json:
{ "name": "my-app", "entry": "src/app.ts", "output": "dist/my-app.wasm" }Named wasmCloud host-interface bindings live in src/bindings.ts (override with "bindings").
Each exported class extending a @di-framework/bindings base and decorated with
@PlatformBinding('name') is discovered statically and added to the WIT requirement graph.
@WasmCloudBinding('name') remains a deprecated alias of the same decorator.
The binding name selects its configuration overlay and normally becomes hostInterfaces[].name.
For wasmcloud:postgres, wasmcloud:keyvalue, wasmcloud:blobstore,
wasmcloud:messaging, and wasmcloud:secrets, host declarations omit the name to
select the provider route that links QuickJS imports. The generated guest world uses unlabeled
import pkg/iface@version statements because jco --backend qjs cannot encode
import name: pkg/iface (cm-implements). Secret values are never taken from source;
secretFrom defaults to <application>-<binding>. Unnamed bindings such as config and
outgoing HTTP retain their overlays through the binding class that contributed the requirements.
Host declarations reflect the interfaces advertised by the runtime: key-value types
is linked internally, and the core links wasi:http/client. Both remain in the guest WIT
imports but are omitted from host discovery. HTTP ingress and outgoing requirements
share one unnamed host declaration per version, retaining configuration from both.
Outgoing requests also require the workload component’s localResources.allowedHosts
to allow the destination; the binding itself does not grant egress access.
Build writes .di-framework/guests.js with real WIT import * as specifiers and installs
those modules on globalThis before the application runs, which is how
@di-framework/bindings constructors receive the guest.
The configured name is the only project identity. The extension owns the WebAssembly/WASI
boundary: it records WIT requirements (the HTTP adapter exports wasi:http/[email protected] today),
generates one world and a wit.lock.json from that graph, bundles the entry behind a WASI-HTTP ↔
Web Fetch adapter, and componentizes with @di-framework/componentize-qjs (a wasmtime-48
fork of componentize-qjs 0.4.4 that can stub imported async funcs such as
wasmcloud:[email protected]). Set DI_FRAMEWORK_COMPONENTIZE_QJS to override the
resolved CLI.
The bootstrap installs text encoding and Fetch globals before evaluating application
imports, including Node modules with module-level constants. node:timers and global
setTimeout, setInterval, and setImmediate use the WASI monotonic clock. Timer
handles support cancellation, refresh, and ref/unref flags; process-lifetime ref
semantics do not apply to an invocation-driven component.
node:async_hooks supplies context scopes and binding. The bundler lowers async
functions and for await loops into Promise continuations, whose callbacks retain
the calling context. Timer callbacks also retain context. Native async-generator
bodies and dynamically evaluated async code are not instrumented by this transform.
A workload must explicitly permit WASI DNS lookups. Add hostnames or wildcard suffixes to the project configuration, for example:
{
"name": "socket-app",
"entry": "src/app.ts",
"allowedIpNameLookups": ["echo.wasmcloud.svc.cluster.local"]
}On a target without a hostgroup, the deployer writes these names under the component's
localResources.allowedIpNameLookups. Omission keeps the host's default denial;
it does not implicitly grant unrestricted DNS access. Tenant targets request egress from
the platform instead; see Egress on tenant targets.
Guest JS keeps the framework's Node contract. The bundler runs unenv
nodeCompat plus a wasmCloud preset: node:path, Buffer, and the rest of
the Node builtin map come from unenv; node:fs and node:fs/promises are an in-memory
filesystem (with ENOENT), except under the storage mount (/data, or DI_STORAGE_DIR), which
reads and writes the host's preopened directory through wasi:[email protected],
process.env / process.cwd() are guest-shaped (not the host process), and createRequire throws
MODULE_NOT_FOUND. node:net and node:dgram overlay WASI 0.3 wasi:sockets (tcp-socket /
udp-socket / ip-name-lookup) so @di-framework/socket's Node TCP/UDP adapters run unchanged.
node:crypto overlays wasi:[email protected] plus guest hashes and the Web Crypto subset used by
socket security (createHash / createHmac / randomBytes / randomUUID / subtle HMAC, HKDF,
AES-GCM, ECDH P-256). node:http is HTTP/1.1 on that TCP overlay (createServer, request /
get, 'upgrade') so the Node WebSocket adapter (ws) can handshake. Those WIT imports are added
to the guest world only when the bundle actually uses them; they are runtime WASI, not wasmCloud
hostInterfaces. node:tls supplies client connect / TLSSocket (including an existing
node:net socket for STARTTLS) through the host's wasi:tls/[email protected] encryption
and decryption streams. node:https supplies HTTP/1.1 request / get and an Agent
that carries connection options; requests wait for verified secureConnect before sending.
child_process stays an unenv mock. Config files from the project (*.json / *.yaml / *.toml / .env) are
seeded into that filesystem at componentize time. Do not put secrets in those files. Stock jco 1.32.1 / componentize-qjs 0.4.4 uses wasmtime 47,
which stubs unknown imports with sync func_new and fails at wizer with
type mismatch with async. Sync imports such as wasi:[email protected] also
componentize with stock jco and run on wasmtime serve -S config. Build state
lives in the disposable .di-framework/ directory.
TLS requires a host with the opt-in wasi-tls feature, such as a TLS-enabled
wash-runtime build, or Wasmtime 48 with -S p3=y,tls=y,inherit-network=y,allow-ip-name-lookup=y.
The draft WIT is pinned to the wasmCloud interface; importing TLS or HTTPS adds it to the
component world automatically. platform dev enables TLS and outbound network access
automatically when using Wasmtime for a component that imports TLS. Other runners need
their own TLS-enabled host configuration. A host without TLS cannot instantiate that component.
See wasmCloud host TLS configuration.
Certificate chain and server-name verification are mandatory and use the host's trust store.
Use servername when connecting by address to a DNS-named service, and configure private
CA trust on the host, including in local development. Guest ca / client certificates,
rejectUnauthorized: false, custom identity checks, TLS versions/ciphers, ALPN, sessions,
and certificate inspection are unsupported and throw explicit errors. Server-side
tls.createServer / https.createServer are unsupported; incoming HTTPS terminates at
the host ingress. This is a client subset, without connection pooling or HTTP/2.
Use tls.connect(options) rather than calling .connect() on a TLSSocket.
HTTPS responses without Content-Length or chunked encoding finish only when the
peer closes the connection. If the peer keeps it open, the response can wait indefinitely.
Set options.timeout and destroy the request in its timeout handler; the timeout
is an inactivity notification and does not cancel the request automatically:
import { get } from 'node:https';
const req = get('https://example.com/', { timeout: 10_000 }, (res) => {
res.on('data', (chunk) => console.log(chunk.toString()));
res.on('error', (error) => console.error(error));
});
req.on('timeout', () => req.destroy(new Error('HTTPS request timed out')));
req.on('error', (error) => console.error(error));Use a separate deadline timer if the entire operation must finish within a fixed time, including a peer that keeps sending data without closing the response.
Run DI_WASI_TLS_SMOKE=1 bun test tests/node-compat-tls-native.test.ts from this package
to compile and exercise both clients against real Wasmtime TLS. This opt-in check needs
the componentizer, Wasmtime 48, OpenSSL, and network access to example.com; it also
starts a temporary local HTTPS server to confirm that an untrusted certificate is rejected.
Package versions are independent of the component-model preview: a WASI 0.3 guest may
still import wasmcloud:* packages at their own versions.
Local platform dev uses wasmtime serve -S cli -S p3 -S config when wasmtime 46+ is on
PATH, then wash dev, then jco serve. A guest that imports wasmcloud:* selects wash
even when wasmtime is present. Set DI_FRAMEWORK_WASMCLOUD_DEV_RUNNER to
wasmtime, wash, or jco to pin one. Wasmtime hosts WASI HTTP and unlabeled
wasi:config locally (-S config-var=key=value to seed values). wasmCloud-only imports
such as wasmcloud:postgres still need wash or a wasmCloud host.
Guest console.log, info, debug, warn, and error write through
wasi:logging/[email protected], one log(level, "console", line) call per line.
QuickJS has no console of its own, and wash 2.8 writes raw guest stdio to the host log
without saying which workload produced it; wasi:logging lines carry workload.name
and workload.namespace. Builds therefore link that import and every WorkloadDeployment
declares an unnamed wasi:logging host interface (interfaces: [logging]). platform dev
links it only for wash; wasmtime and jco cannot provide it, so those builds keep a
console that discards output.
Guest console lines format values the way Node's console does where JSON allows it.
WIT s64/u64 values (SQLite integers, row counts, timestamps) reach JavaScript as
BigInt. A top-level BigInt prints as 42n, as in Node, so console.log(3n) writes 3n
rather than 3. A BigInt nested in an object or array prints as the JSON string "42n"
({"id":"42n"}, where Node prints { id: 42n }) so the line stays valid JSON. Log
tooling cannot tell that value from the string "42n"; treat a string field matching
^-?\d+n$ as a BigInt where the difference matters.
Logs are on by default. Set "logs": false in di-framework.config.json to opt a project out:
{ "name": "quiet-app", "entry": "src/app.ts", "logs": false }The build then links no wasi:logging import and keeps the console that discards output, in
platform deploy and platform dev alike. The WorkloadDeployment declares no wasi:logging
host interface and carries the annotation di-framework.dev/logs: "false", so the platform
publishes none of the application's console lines. It still publishes the host's WARN and
ERROR lines for the application's workloads (for example a workload that failed to start)
with the application's logs, and the console still uses them to mark a failed workload.
Each workload member reads its own config, so the setting applies per member. logs must
be a boolean; omitting it or setting true keeps logs on and adds no annotation.
wash 2.5.x has no --address flag. The extension writes
.di-framework/wash-dev.yaml (dev.address, host_interfaces,
wasm_proposals: [component-model-async]) and runs wash dev --user-config against it.
Set WASMCLOUD_POSTGRES_URL to populate dev.postgres_url; never put secrets in source.
Deployment manifest
Deployment topology lives in di-framework.deploy.toml at the workspace root. The CLI finds it by
walking upward from the current directory. The file describes targets, not applications: there
is no apps table, and projects may live in any directory layout.
default-target = "local"
[targets.local]
platform = "deploy/platform"
stack = "dev"
[targets.development]
kubeconfig = "${KUBECONFIG}"
context = "team-development"
namespace = "wasmcloud"
[targets.development.registry]
push = "https://registry.example.com/team"
pull = "registry.internal.example.com/team"
insecure = false
[targets.warehouse]
kubeconfig = "${WAREHOUSE_KUBECONFIG}"
tenant = "warehouse" # namespace di-tenant-warehouse, hostgroup tenant-warehouse
registry = "registry.example.com/warehouse"- Every command that takes
--target(deploy,destroy,console,service …, andcluster up/cluster destroy) usesdefault-targetwhen--targetis left out. Pass--target <name>only to pick a different target. di-framework platform deploywith no name uses the nearestdi-framework.config.json.di-framework platform deploy greeterrecursively discovers projects (skipping.git,node_modules,.di-framework, and generated output by default) and matches the configuredname. Duplicate names fail with every conflicting path.${VAR}interpolation fails if the variable is unset or empty. Do not put credentials in the manifest.
Targets and tenants
A target and a tenant are different things:
- A target is a local deploy destination: one
[targets.<name>]table indi-framework.deploy.toml. It bundles a credential (kubeconfig and optional context), a namespace, a host group, and a registry. Only your workspace knows about it. - A tenant is the platform's isolation unit, declared by the platform administrator. The
platform gives tenant
<t>the workload namespacedi-tenant-<t>, the runtime namespacedi-runtime-<t>, the host grouptenant-<t>, and RBAC for its users.
One tenant can have several targets, for example one per user, credential, or registry. A target
points at no tenant (an admin or plain-cluster target such as local or development above) or
exactly one. Set tenant = "<t>" on an external target to point it at a tenant: it fills in
namespace = "di-tenant-<t>" and hostgroup = "tenant-<t>". An explicit namespace or
hostgroup still wins. Tenant names follow the platform's rule: 1–40 characters, lowercase
letters, digits, and single hyphens, starting with a letter. An external target needs
kubeconfig, registry, and either tenant or namespace.
Managed Pulumi target
From the workspace root, generate a self-contained local platform (k0s, a local OCI registry, and the wasmCloud operator) from templates shipped with this extension:
di-framework platform cluster init
di-framework platform cluster up --yesplatform init writes deploy/platform and creates or updates di-framework.deploy.toml so
local is a managed target (platform = "deploy/platform", stack = "dev"). Existing files are
left alone unless you pass --force. When the manifest has no default-target yet, local
becomes the default, so the commands below need no target name. The command prints the exact start command when it finishes.
Platform deploy runs the package-manager-neutral pulumi install command automatically, so the
generated project works immediately in a blank consumer workspace without a root workspace entry
or a manual install inside deploy/platform.
The generated Pulumi project provisions only platform concerns. It has workspace- and stack-scoped
Docker names, a dedicated network, persistent k0s state/log volumes, pinned images and chart,
readiness checks, and loopback-only high ports. It must not contain application names, component
builds, application Services, or WorkloadDeployments. The defaults are Kubernetes 26443, registry
25000, and HTTP 28180; set apiPort, registryPort, or httpPort with pulumi config set in
deploy/platform to choose another distinct port from 1024 through 65535.
The CLI reads a small output contract from pulumi stack output --json:
| Output | Required | Meaning |
| --- | --- | --- |
| kubeconfig | yes | kubeconfig YAML or a filesystem path |
| namespace | yes | Kubernetes namespace for workloads |
| registry | yes | legacy string shorthand, or { push, pull, insecure } transport object |
| context | no | kubectl context |
| endpoints.http / endpoints.kubernetes / endpoints.registry | no | optional URLs |
Provision and tear down that stack explicitly (add the target name, positionally or with
--target, when it is not the default-target):
di-framework platform cluster up --yes
di-framework platform cluster destroy --yesApplication destroy never runs pulumi destroy.
Control Secret
An application that serves HTTP, cron, queues or actors gets a Secret
<deployment>-control holding DI_CONTROL_TOKEN and DI_CONTROL_IDENTITY. The workload gets
the whole Secret as environment variables, and a workload without an environment label may
reference no other Secret, so it is also where an app keeps its own runtime secrets. Declare
those keys in di-framework.config.json:
{
"controlSecretKeys": ["ISSUER_URL", "AUTH_ACTIVE_PRIVATE_JWK", "DI_PGLITE_KEY"]
}A declared key must be an environment variable name. DI_* names belong to the framework, so
of those only DI_PGLITE_KEY and DI_PGLITE_KEY_PREVIOUS may be declared, and so may none of
the names the CLI renders into the workload's environment (ACTOR_STORAGE_DIR,
QUEUE_DB_PATH, MIGRATION_DB_PATH). Deploy never writes or reads values for these keys, and
refuses any key that is neither the CLI's nor declared.
Once app keys exist, the app's tooling owns the control token. A tenant developer cannot read the Secret, so the tooling cannot keep the CLI's token value. It replaces the whole Secret with:
- its own values;
- a freshly minted, non-empty
DI_CONTROL_TOKEN(for example 32 random bytes, base64url); DI_CONTROL_IDENTITYset to the workload name.
It then rolls the workload, for example by setting a revision entry in the WorkloadDeployment's
environment config, as identity-server's runtime-secrets.ts apply does. From then on deploy
keeps that Secret, and token included, never rotates it. Rotate by re-running the tooling. The
platform issue
di-framework/platform#203 proposes a
separate <workload>-secrets so the control Secret can go back to being CLI-owned and rotated
on every deploy.
A new Secret is created with the CLI's keys and a new random token.
Tenant developers may create, update and delete Secrets but not read, list, watch or patch them. The CLI never reads the Secret back for them;
kubectl auth can-i getdecides this without sending a read. Every check below is a dry-run update (?dryRun=All, nothing stored). It asks the platform'stenant-secret-updateadmission policy, which refuses an update that drops a key, about key names only. With app keys declared, deploy:- Checks that the policy protects the Secret. A dry run keeping only
DI_CONTROL_IDENTITYmust be refused. If it is accepted, the policy does not apply to this caller (a write-only identity that is notdi-user-*, or a namespace outside the policy), or the Secret has no token. A blind replace could then drop the app keys for good, so deploy stops withWASMCLOUD_CONTROL_SECRET_UNGUARDEDand writes nothing. As a result, a write-only caller outsidedi-user-*(for example a CI ServiceAccount bound to the developer role) cannot deploy a project that declares app keys. Deploy it as a tenant developer, or with an identity that may read Secrets. - Replaces the whole Secret with the CLI's keys and a new token. That succeeds, and rotates the token, only while the Secret holds nothing else.
- If the replace is refused, sends a dry run with the CLI's keys and every declared key. If
the policy refuses that too, an undeclared key exists: deploy stops with "the control
Secret
<deployment>-controlhas keys this CLI didn't write". Declare the keys, or delete the Secret withkubectl delete secret <deployment>-controland redeploy. - Leaves out
DI_CONTROL_TOKEN, thenDI_CONTROL_IDENTITY, in two more dry runs. Each must be refused, which proves the key is stored. Otherwise the app tooling dropped it, and deploy stops withWASMCLOUD_CONTROL_SECRET_INCOMPLETErather than run a workload without control credentials. The checks see key names only, so a present but emptyDI_CONTROL_TOKENpasses them. The deployed workload then has no usable token and answers every control request (cron, queue and actor endpoints) with 401; it never falls back to unauthenticated access. Make sure the tooling writes a non-empty token. - Keeps the Secret as it is, token included, says "Kept control Secret
<deployment>-control", and renders noDI_CONTROL_TOKEN_REVISION.
These checks rely on
tenant-secret-updatehaving no dry-run exemption (di-framework/platform#204). Without declared keys, deploy only replaces, as before.- Checks that the policy protects the Secret. A dry run keeping only
A caller who may read Secrets (a cluster admin) gets the Secret read. Deploy stops, naming the keys, when one is neither the CLI's nor declared, instead of dropping it. Otherwise it writes a new token with the declared keys carried over, conditional on the version it read, and a concurrent change stops deploy with "changed while deploy was updating it; re-run deploy". Nothing read is printed. Failures are scrubbed of every value read: values of four characters or more wherever they appear, and shorter ones as whole tokens.
Other failed writes show kubectl's reason, scrubbed of the token.
When deploy writes a token, it rotates at that Secret write, before
kubectl apply. The WorkloadDeployment carriesDI_CONTROL_TOKEN_REVISION(a digest of the token), so the deploy rolls the workload onto the new token, but not at the same moment as its cron invokers: a CronJob pod reads the Secret when it starts, so the invokers switch first and can get 401 from the workload until its rollout is ready (the Job's retries normally absorb this).A deploy that fails after the Secret write (a failed
apply, a rejected WorkloadDeployment, a rollout that never becomes ready, or an interrupted deploy) leaves the running workload on the old token while the invokers use the new one, so cron invocations keep failing until the next successful deploy. A failed deploy's error then ends with "the control token was rotated; re-run deploy to roll the workload onto it".destroydeletes the Secret by name, after the WorkloadDeployment, Service and CronJobs. It deletes only<deployment>-control: Secrets are no longer part of its label-selected delete (that would list them), so a Secret you labelledapp.kubernetes.io/name=<deployment>yourself stays; delete it by name.The console's credential reassignment replaces the named Secret with one holding only
credential; labels and annotations on it are not kept. It works only on a Secret whose single key iscredential: the console cannot read the Secret, so the platform'stenant-secret-updateadmission policy refuses a replace that drops a key, and the console answers 409. So does a Secret whose type is notOpaque, since the API server refuses to change a Secret's type. Platform-managed Secrets (di-binding-*,di-bs-*,di-tenant-stock,di-platform-routes) are refused with 403 before any request, and an admission denial for a managed name also answers 403.
The generated local target publishes through its loopback registry NodePort and puts the equivalent
in-cluster registry address in the WorkloadDeployment. Both references use the same repository and
stable canonical-input tag. An http:// push URL or insecure = true adds ORAS --plain-http only
for that target; TLS remains the default everywhere else.
Existing cluster
When kubeconfig and a registry are already available, declare an external target with only access
information (as development above) and deploy:
export KUBECONFIG="$HOME/.kube/config"
di-framework platform deploy greeter --target developmentApplication deploy
For the selected project the extension:
- Builds the component.
- Publishes it with
orasfrom the project root using project-relative artifact paths and a stable canonical-input reference (<registry>/<wit-name>:sha256-<deployment-digest>). The actual component-byte digest is calculated and reported separately because ComponentizeJS snapshots may vary byte-for-byte for identical inputs. - Derives a wasmCloud
WorkloadDeploymentand KubernetesService(written under.di-framework/deploy/, not checked in). - Configures
wasi:http/[email protected]with the project name as its host, applies the resources, and waits for currentReady=Trueor compatible older readiness schemas.
Console
di-framework platform console serves a local control panel for one tenant. The tenant credential
is the only authentication: there is no login. The process binds to loopback and keeps that
credential in memory.
The console shows each deployed application as a workload of services and components. A developer can read its routes, environment, secret names, backing-service bindings, private bindings, and logs. Secret values are write-only. Routes can be turned on or off without a rebuild. Logs and success or compute signals appear only when the platform publishes them for that application.
default-target = "warehouse"
[targets.warehouse]
kubeconfig = "${WAREHOUSE_KUBECONFIG}"
tenant = "warehouse"
registry = "registry.example.com/warehouse"di-framework platform console
di-framework platform console --port 8790Without --port the system picks a free port. The console prints Console listening on <url> with
the real address as soon as it is serving, also when stdout is redirected to a file.
--target defaults to default-target. The target must be a tenant credential (a tenant user's
kubeconfig with tenant, or with namespace and hostgroup), not the platform admin
credential. A viewer credential sees the same screens and cannot change them.
For the generated local platform the result reports the HTTP URL and required Host header. It is
directly reachable without kubectl port-forward, for example:
curl -H 'Host: greeter' http://127.0.0.1:28180/Demonstration layout
A complete workspace is in examples/workspace: deploy/platform plus
projects at services/greeter and nested/deep/echo. Copy that tree or start from the TOML
above.
The manifest contract for extensions is documented in
@di-framework/cli-extension.
Live integration test
The opt-in test packs the CLI and extension dependencies, installs those tarballs in a blank temporary workspace, and runs the full platform/application lifecycle against Docker:
DI_FRAMEWORK_WASMCLOUD_LIVE=1 bun test packages/cli-plugin-platform/tests/live-workflow.test.tsIt requires Docker, Pulumi, kubectl, ORAS, npm, Bun, and curl. The test selects unused loopback
ports and removes its scoped platform resources in a finally cleanup.
Actor Integration with wasmCloud
The wasmCloud plugin natively integrates virtual actors from @di-framework/actors into WebAssembly components and Kubernetes deployments.
1. Build-Time Actor Scanning & Dispatch Generation
- Static Discovery: During
di-framework platform build, source files are scanned for@Actorand@ActorMethoddecorators using TypeScript AST analysis. - Dispatch Module Generation: The build generates
.di-framework/actors.jswhich explicitly imports actor classes, registers them withActorRuntime, sets upSqliteActorStorage, and exportsdispatchActorInvocation. This prevents registered actors and their methods from being eliminated by Rolldown tree-shaking. - Private Invocations: Invocations are delivered privately via
/_actors/invokeor private service bindings without exposing public HTTP routes.
2. Runtime Execution Model
- Activation & Scheduling: Actor activations live in-memory within the host component instance. Per-actor mailbox queues asynchronously serialize calls to the same actor identity (
namespace:actorName:actorKey) while allowing distinct actors to execute concurrently. - Host Storage Capabilities: Persistent storage is bound via host capabilities (filesystem volume mount at
/data/actors, configured viaACTOR_STORAGE_DIR). Each actor has an isolated SQLite database file with single-writer process file locking. - Transactions & Migrations: Method invocations execute within actor-scoped transactions that commit on success and roll back on errors. Schema migrations run automatically before an actor's first activation; migration failures reject activation before calls can proceed.
3. Deployment & Operating Safety Constraints
- Single-Host Constraint: To ensure data consistency and prevent database split-brain with SQLite file locking, actor workloads enforce
replicas: 1. Accidental configurations withreplicas > 1are rejected withWASMCLOUD_ACTORS_REPLICA_CONSTRAINT. - Persistent Volumes: Generated manifests provision a Kubernetes
PersistentVolumeClaimmounted at/data/actors. - Upgrade & Drain Behavior: The workload uses Kubernetes rollout
strategy: { type: "Recreate" }, guaranteeing that the terminating pod drains active calls and releases SQLite locks before the new version activates and executes pending migrations. - Single-Host vs. Distributed: Single-host wasmCloud actor deployment is designed for standalone, resilient edge or single-node deployments. Distributed actor clustering, key partitioning, and remote consensus across wasmCloud nodes are part of distributed actor capabilities.
Actor HTTP dispatch is restricted to the reserved /_actors/ path. Actor headers on other paths do not intercept application requests. Error responses expose stable error names and generic messages; they omit internal exception details and migration objects.
Building from source
The package build compiles the SQLite provider from
../di-framework-sqlite-component using its pinned Rust, WASI SDK, and wasm-tools
versions. Install that toolchain from the repository root before bun install
(the root postinstall builds the workspace):
bash packages/di-framework-sqlite-component/scripts/install-tools.sh --rust
bun install --frozen-lockfileSubsequent bun run build runs rebuild the provider through Cargo's incremental
build cache and stage its WASM, WIT, metadata, and checksums under
dist/assets/sqlite. Generated artifacts are ignored by Git and included in the
published npm package; npm consumers do not need the Rust build toolchain.
Implicit workloads
A package can declare "workload": "warehouse" in di-framework.config.json.
The CLI discovers other packages with that name and generates
.di-framework/workloads/warehouse.json at the deployment workspace root. No parent
application or explicit member list is required. Member deployments remain independent
and carry a di-framework.dev/workload Kubernetes label.
In the entry module, export one variable wrapped in WorkloadComponent({ path: '/take' })
or WorkloadService({ path: '/sync', subscriptions: ['warehouse.stock'] }), imported from
@di-framework/bindings. The workload name lives in config; source describes behavior.
The CLI statically reads literal options, rejects conflicting member paths, and selects
the exported handler without requiring a default export. A service with subscriptions
exports wasmcloud:messaging/[email protected] and deploys as a component; a service without
subscriptions exports wasi:cli/[email protected] and deploys as the workload's
spec.template.spec.service, the only place wash runs a long-lived program. Both initialize
declared host bindings before loading the entrypoint.
The inferred manifest records routes but does not install a shared HTTP router. On the current Kubernetes runtime, HTTP members retain their individual Host headers. See the warehouse example for the complete deployment.
Tenant deployment targets
Each external target selects a user's credentials, namespace, registry, and host groups. Two
users of the warehouse tenant each get their own target:
default-target = "alice"
[targets.alice]
kubeconfig = "${ALICE_KUBECONFIG}"
context = "alice"
tenant = "warehouse"
[targets.alice.registry]
push = "https://registry.example.com/alice"
pull = "registry.example.com/alice"
[targets.bob]
kubeconfig = "${BOB_KUBECONFIG}"
tenant = "warehouse"
registry = "registry.example.com/bob"Run di-framework platform deploy warehouse-take as alice, or add --target bob. All Kubernetes calls,
including deletion and diagnostics, use that target's kubeconfig, context, and namespace.
The generated WorkloadDeployment also sets spec.template.spec.environment to the target
namespace, so host selection cannot silently fall back to another environment when the
matching pool is unavailable. hostgroup defaults to default. These selectors refer to
existing host pools; application deployment does not provision them.
A target with a hostgroup is a tenant target, and its storage belongs to the platform.
Actors, queues, workers, PGlite guests, and persistentStorage: true deploy with the annotation
di-framework.dev/persistent-storage: "true" (plus di-framework.dev/storage-mount:
"/data/actors" for actors) and no volume, volume mount, or host path. The platform
controller creates <storageRoot>/di-tenants/<tenant uid>/workloads/<workload>, shared by
the members of one di-framework.dev/workload and private to the tenant, and injects the
volume and the /data preopen. The guest still reads DI_STORAGE_DIR; node:fs paths under
it reach that directory, so members see each other's files. storage-hostgroup
applies only to targets without a hostgroup, which keep the host path under
/var/lib/di-framework/storage on the storage pool.
Generated managed platforms set operator.allowSharedHosts: false. Existing generated
platforms and external clusters, including di-framework-kube, need their operator Helm
values updated separately. Application deploy does not reconfigure the operator.
Generated platforms provision namespaces, host pools, isolated Redis/NATS backends, network policies, quotas, ServiceAccounts, and RBAC from declarative Tenant/User resources. Administrators issue expiring ServiceAccount tokens; no identity provider or login service is required. External clusters need equivalent provisioning. The local registry remains shared and unauthenticated. Never distribute the platform's administrative kubeconfig as a user's deployment credential.
An existing target that intentionally used a host in another environment must now have a host in its own namespace environment. Workload names and decorator paths remain local to the implicit workload; they do not select user identity or Kubernetes namespaces.
Generated platforms support declarative tenants and users in their Pulumi stack configuration. See the generated platform guide for CRDs, roles, token issuance, and lifecycle behavior.
Embedded PostgreSQL (PGlite)
A guest that imports di-framework:pglite/[email protected] gets the
PGlite provider
plugged in after componentize (wac plug, like the SQLite provider). The database runs inside
the component: there is no wasmcloud:postgres binding, service, or connection string. Pin the
published provider by digest in di-framework.config.json:
{
"name": "orders",
"entry": "src/app.ts",
"pglite": {
"image": "ghcr.io/di-framework/pglite-component@sha256:<64 hex>",
"storageMount": "/data",
"dataDir": "/data/pglite"
}
}import { open } from 'di-framework:pglite/[email protected]';
const db = open(process.env.DI_PGLITE_DATA_DIR ?? '/data/pglite', undefined);| Field | Default | Meaning |
| --- | --- | --- |
| image | required unless wasm is set | <repository>@sha256:<digest>, the manifest digest the platform publish job records. Tags are refused because they move. |
| wasm | none | A local provider build inside the project (symlinks resolved), used instead of image for offline development. Unverified; see below. |
| storageMount | /data | Guest preopen of the workload's persistent directory. Tenant targets accept /data or /data/actors. |
| dataDir | <storageMount>/pglite | Database root, at or under storageMount. The guest receives it as DI_PGLITE_DATA_DIR. |
| encryption | none | { "image": "ghcr.io/di-framework/pglite-encfs@sha256:<digest>" } (or a local wasm): encrypt the data directory at rest; see Encryption at rest. |
The build pulls the provider with oras: oras manifest fetch, then oras blob fetch of the
manifest's single application/wasm layer. It checks that the manifest hashes to the pinned
digest and the layer to the digest the manifest names, then caches both at
oci/sha256/<digest>/ under $DI_FRAMEWORK_CACHE_DIR (default $XDG_CACHE_HOME/di-framework,
else ~/.cache/di-framework). A cached entry is verified again on every build and pulled again
if it no longer matches, and stale .pull-* staging directories (older than a day) are removed.
The build plugs a private copy of the verified bytes (.di-framework/di-framework-pglite.wasm),
so a later change to the shared cache cannot reach the composed component. While the GHCR
package is private, run oras login ghcr.io first.
DI_FRAMEWORK_PGLITE_WASM=/path/to/di-framework-pglite.wasm overrides both wasm and image.
A local provider is only checked to be WebAssembly, never against the digest, and the build prints
a Warning: on stderr when it uses one. platform deploy, and any build with CI set, refuse a
local provider unless DI_FRAMEWORK_ALLOW_LOCAL_PGLITE=1 is set. The deploy result reports the
provider's source: the digest pin, or the local path marked unverified. Don't commit wasm
next to image: it replaces the pin on every build.
The build fails when the guest imports the database without a pglite entry, or when a pglite
entry has no matching import, because the deploy renders the following from the config alone:
- The workload's single component gets
localResources.configdi-framework.dev/exclusive-instance: "true", so the tenant host runs at most one instance of it. The key is never put on a WorkloadService, which admission rejects; a guest shaped as a service deploys as a component. The opt-in takes effect only on the exclusive tenant host image (ghcr.io/di-framework/wash:2.8.0-wasi-tls-exclusive, selected with the platform'stenantHostImage); see the platform'stenant-host/README.md. On stock wash the key is ignored, and a saturated or draining pool builds a second instance that opens the same data directory.platform deploywarns on stderr when a tenant host group's pod image is not tagged*-exclusive. On other targets, including thestoragehost group, it cannot see the host and prints a note instead. - Persistent storage, as for
persistentStorage: true. Tenant targets get thedi-framework.dev/persistent-storage: "true"annotation, plusdi-framework.dev/storage-mountwhenstorageMountis not/data. Other targets get a host path volume on thestoragepool. replicas: 1anddeployPolicy: Recreate. Other replica counts are refused.
Encryption at rest
pglite.encryption plugs an encrypting wasi:filesystem layer between the provider and the
host, so the data directory holds only ciphertext. The layer is composed into the provider
first, with an explicit wac compose (see below). That result is then plugged into the guest
exactly as an unencrypted provider is. The guest's own WASI imports still go straight to the
host.
{
"controlSecretKeys": ["DI_PGLITE_KEY"],
"pglite": {
"image": "ghcr.io/di-framework/pglite-component@sha256:<64 hex>",
"encryption": { "image": "ghcr.io/di-framework/pglite-encfs@sha256:<64 hex>" }
}
}encryption.imageis pinned by digest and pulled, verified and cached like the provider (oci/sha256/<digest>/di-framework-pglite-encfs.wasm); the build plugs a private copy,.di-framework/di-framework-pglite-encfs.wasm.encryption.wasm(a local build inside the project) andDI_FRAMEWORK_PGLITE_ENCFS_WASMoverride it under the same rules as a local provider: unverified, warned about, and refused byplatform deployand on CI unlessDI_FRAMEWORK_ALLOW_LOCAL_PGLITE=1.The layer reads its master key from
DI_PGLITE_KEY(and, while rotating,DI_PGLITE_KEY_PREVIOUS). The key reaches the guest only through the workload's control Secret, so the build fails withWASMCLOUD_PGLITE_KEY_UNDECLAREDunlessDI_PGLITE_KEYis incontrolSecretKeys(see Control Secret). That check runs first, before bundling or any pull. The CLI never writes or reads the key: put it in<deployment>-controlwith your own tooling, and keep a copy, since losing it loses the database.The layer must sit between the provider and the host, and
wac plugcannot prove that.wac plugsilently skips an export whose type does not fit, and it exits 0 as long as anything plugged. So the build does this instead:- Reads both worlds with
jco wit. - Takes, for each provider import the layer exports, the export with the same name and
version, or else one on the same semver track the way
wacmatches them. That means the same major from 1.0 on, the same minor for 0.x, and an exact match for 0.0.z and pre-releases. Everywasi:filesystem/*import must get one. - Writes
.di-framework/pglite-encryption.wac. It instantiates the layer against the host and passes each of those exports to the provider as a named instantiation argument. - Composes it with
wac compose, which type-checks every argument.
The build stops with
WASMCLOUD_PGLITE_ENCRYPTION_NOT_INTERPOSED, carryingwac's message when there is one, if any of these holds:- the layer misses a filesystem interface, is on another version track, or exports a type the provider cannot use;
- the provider's world has an item the build cannot read as a versioned interface (such as a
plain-named
import fs: interface { … }); - the provider imports something that is neither wired to the layer nor on the host
pass-through allowlist (
wasi:io/*,wasi:clocks/*,wasi:random/*,wasi:cli/*).
Build the layer for the WASI versions the pinned provider imports (
wasi:filesystem/*@0.2.9for provider 6.0.11). A missingwacorjcofails withWASMCLOUD_WAC_REQUIREDorWASMCLOUD_JCO_REQUIRED.- Reads both worlds with
The layer must carry the platform's
di-framework:pglite-encfscustom section (format=1 codec=aes-256-xts). Without it the build stops withWASMCLOUD_PGLITE_ENCRYPTION_LAYER_UNMARKED, which catches a no-op or debug layer pinned by mistake or a stale local build. The build proves the wiring and the layer's identity, not the cipher: the marker can be forged, so trust in what the layer actually does comes from the digest pin ofencryption.image.Only the engine has
DI_PGLITE_KEYhidden. The layer filters it from the provider's environment, but the guest's ownwasi:cli/environmentstill comes from the host, so your application code can read the key.The composed provider is
.di-framework/di-framework-pglite.encrypted.wasm. The deployment digest hashes the raw provider and layer bytes, notwac's output, so changing either one redeploys. The build and deploy results report the layer (PGlite: <image>, encrypted at rest by <layer>).encryption.imagemust not pin the provider's repository or digest.
Give each database one owner. Members of one implicit workload share one storage directory,
mounted at each member's own storageMount. So /data/pglite under a /data mount and
/data/actors/pglite under a /data/actors mount are the same database. Members that both embed
PGlite need different dataDir values relative to storageMount. platform deploy refuses a
workload where two members resolve to the same root (WASMCLOUD_PGLITE_DATA_DIR_CONFLICT).
platform dev composes the provider but does not support PGlite yet. It mounts no storage
directory and sets no DI_PGLITE_DATA_DIR, so open fails locally. Exercise the database
against a deployed workload, or run the composed component with
wasmtime run --dir ./data::/data as the provider README shows.
Managed PostgreSQL (upcoming release)
di-framework platform service create postgres --name orders --wait provisions
a dedicated instance through the default postgres-dedicated class. A
@WasmCloudBinding('orders-db', { serviceName: 'orders' }) on a Postgres subclass
selects it. Deployment validates references and waits for controller-managed
ServiceBindings before applying the workload. Successful updates and workload
deletion remove obsolete deployment-owned associations. Shared projections stay
while other workloads use them.
See PostgreSQL storage, deletion, and recovery.
Blobstore services
di-framework platform service create blobstore --name catalog --wait provisions a
JetStream NATS object store through the default blobstore-nats class. It takes the
same --class, --memory, --storage, --cpu, --deletion-policy, and --timeout
flags as the other types. Bind it to an application in the console (or with a
ServiceBinding whose bindingName is objects and capability is blobstore); the
controller projects ConfigMap di-binding-objects, and the guest selects it with
configFrom:
@WasmCloudBinding('objects', { configFrom: 'di-binding-objects' })
@Container()
export class MeshObjects extends Blobstore {}The host interface stays unnamed; configFrom is the only addition. Each guest
container becomes its own object-store bucket. An unnamed blobstore without
configFrom uses the host default, which in wash 2.8 is an in-memory store that is
neither shared between components nor kept across restarts, so a workload whose
members share objects should bind a created service. serviceName stays
Postgres-only.
Egress on tenant targets
Tenant credentials cannot write allowedHosts or allowedIpNameLookups into a
WorkloadDeployment. On a tenant target (one with a hostgroup), deploy turns the
project's allowedIpNameLookups into a platform request instead:
- BackingService
<deployment>-egresswithspec.type: egressandspec.destinationsset to the names, and - ServiceBinding
<deployment>-egresswithcapability: egress,bindingName: egress, andworkloadName: <deployment>.
<deployment> is the WorkloadDeployment name. Deploys update the destinations when the
names change, and remove both resources when the setting is removed or the application is
destroyed. The resources carry app.kubernetes.io/managed-by: di-framework; deploy refuses
to adopt same-named resources it did not create. "*" cannot be requested on a tenant
target. The WorkloadDeployment itself carries neither field: once the platform approves
the destinations, its controller patches allowedHosts and allowedIpNameLookups in and
opens the tenant host's network policy for the approved ports.
Approval comes from the egress class policy (egressAllowedDestinations in the platform's
Pulumi config, empty by default). After the rollout, deploy prints the approved host:port
entries, or a note when the service is NotApproved or not yet Ready. The deploy itself
still succeeds; outbound connections stay blocked until a platform admin allows the
destination.
You can also create and bind an egress service yourself:
di-framework platform service create egress outbound \
--destination api.example.com:443 --destination '*.example.org'
di-framework platform service get outbound # Destinations / Approved / Ready reasonEach destination is host, *.suffix, host:port, or *.suffix:port; a destination
without a port is approved for the policy ports that match its name. Bind it from the
console's Bindings tab (single-part applications only, since egress is granted per
WorkloadDeployment). list, get, and delete work for egress like the other types; the
console create form does not offer egress because it does not collect destinations.
TLS. If the built component imports wasi:tls and the target is a tenant
(hostgroup = "tenant-<t>"), deploy reads the image of the host pods labelled
wasmcloud.com/hostgroup=tenant-<t> in di-runtime-<t>. Unless the image tag contains
wasi-tls (for example …/wash:2.8.0-wasi-tls), it warns that TLS connections will fail;
stock ghcr.io/wasmcloud/wash:<version> images have no wasi:tls provider. The check
never blocks the deploy and stays silent when the pods cannot be read.
