npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 checks

Run 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 …, and cluster up / cluster destroy) uses default-target when --target is left out. Pass --target <name> only to pick a different target.
  • di-framework platform deploy with no name uses the nearest di-framework.config.json.
  • di-framework platform deploy greeter recursively discovers projects (skipping .git, node_modules, .di-framework, and generated output by default) and matches the configured name. 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 in di-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 namespace di-tenant-<t>, the runtime namespace di-runtime-<t>, the host group tenant-<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 --yes

platform 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 --yes

Application 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_IDENTITY set 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 get decides this without sending a read. Every check below is a dry-run update (?dryRun=All, nothing stored). It asks the platform's tenant-secret-update admission policy, which refuses an update that drops a key, about key names only. With app keys declared, deploy:

    1. Checks that the policy protects the Secret. A dry run keeping only DI_CONTROL_IDENTITY must be refused. If it is accepted, the policy does not apply to this caller (a write-only identity that is not di-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 with WASMCLOUD_CONTROL_SECRET_UNGUARDED and writes nothing. As a result, a write-only caller outside di-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.
    2. 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.
    3. 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>-control has keys this CLI didn't write". Declare the keys, or delete the Secret with kubectl delete secret <deployment>-control and redeploy.
    4. Leaves out DI_CONTROL_TOKEN, then DI_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 with WASMCLOUD_CONTROL_SECRET_INCOMPLETE rather than run a workload without control credentials. The checks see key names only, so a present but empty DI_CONTROL_TOKEN passes 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.
    5. Keeps the Secret as it is, token included, says "Kept control Secret <deployment>-control", and renders no DI_CONTROL_TOKEN_REVISION.

    These checks rely on tenant-secret-update having no dry-run exemption (di-framework/platform#204). Without declared keys, deploy only replaces, as before.

  • 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 carries DI_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".

  • destroy deletes 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 labelled app.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 is credential: the console cannot read the Secret, so the platform's tenant-secret-update admission policy refuses a replace that drops a key, and the console answers 409. So does a Secret whose type is not Opaque, 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 development

Application deploy

For the selected project the extension:

  1. Builds the component.
  2. Publishes it with oras from 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.
  3. Derives a wasmCloud WorkloadDeployment and Kubernetes Service (written under .di-framework/deploy/, not checked in).
  4. Configures wasi:http/[email protected] with the project name as its host, applies the resources, and waits for current Ready=True or 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 8790

Without --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.ts

It 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 @Actor and @ActorMethod decorators using TypeScript AST analysis.
  • Dispatch Module Generation: The build generates .di-framework/actors.js which explicitly imports actor classes, registers them with ActorRuntime, sets up SqliteActorStorage, and exports dispatchActorInvocation. This prevents registered actors and their methods from being eliminated by Rolldown tree-shaking.
  • Private Invocations: Invocations are delivered privately via /_actors/invoke or 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 via ACTOR_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 with replicas > 1 are rejected with WASMCLOUD_ACTORS_REPLICA_CONSTRAINT.
  • Persistent Volumes: Generated manifests provision a Kubernetes PersistentVolumeClaim mounted 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-lockfile

Subsequent 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.config di-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's tenantHostImage); see the platform's tenant-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 deploy warns on stderr when a tenant host group's pod image is not tagged *-exclusive. On other targets, including the storage host group, it cannot see the host and prints a note instead.
  • Persistent storage, as for persistentStorage: true. Tenant targets get the di-framework.dev/persistent-storage: "true" annotation, plus di-framework.dev/storage-mount when storageMount is not /data. Other targets get a host path volume on the storage pool.
  • replicas: 1 and deployPolicy: 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.image is 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) and DI_FRAMEWORK_PGLITE_ENCFS_WASM override it under the same rules as a local provider: unverified, warned about, and refused by platform deploy and on CI unless DI_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 with WASMCLOUD_PGLITE_KEY_UNDECLARED unless DI_PGLITE_KEY is in controlSecretKeys (see Control Secret). That check runs first, before bundling or any pull. The CLI never writes or reads the key: put it in <deployment>-control with 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 plug cannot prove that. wac plug silently skips an export whose type does not fit, and it exits 0 as long as anything plugged. So the build does this instead:

    1. Reads both worlds with jco wit.
    2. 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 wac matches 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. Every wasi:filesystem/* import must get one.
    3. 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.
    4. Composes it with wac compose, which type-checks every argument.

    The build stops with WASMCLOUD_PGLITE_ENCRYPTION_NOT_INTERPOSED, carrying wac'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.9 for provider 6.0.11). A missing wac or jco fails with WASMCLOUD_WAC_REQUIRED or WASMCLOUD_JCO_REQUIRED.

  • The layer must carry the platform's di-framework:pglite-encfs custom section (format=1 codec=aes-256-xts). Without it the build stops with WASMCLOUD_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 of encryption.image.

  • Only the engine has DI_PGLITE_KEY hidden. The layer filters it from the provider's environment, but the guest's own wasi:cli/environment still 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, not wac's output, so changing either one redeploys. The build and deploy results report the layer (PGlite: <image>, encrypted at rest by <layer>).

  • encryption.image must 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>-egress with spec.type: egress and spec.destinations set to the names, and
  • ServiceBinding <deployment>-egress with capability: egress, bindingName: egress, and workloadName: <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 reason

Each 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.