Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
14 KiB
Alga PSA Extension Development Guide (Runner + Iframe UI)
This guide describes how to build Enterprise Edition (v2) extensions for Alga PSA:
- Server-side handlers execute out-of-process in the Runner as Wasmtime components produced by
componentize-js - UI is rendered exclusively in sandboxed iframes
- Bundles are signed and content-addressed (sha256:...) and validated by the Registry
Core rules: See “Correctness Rules” in the README for the canonical list. Reference gateway scaffold: server/src/app/api/ext/[extensionId]/[[...path]]/route.ts
Prerequisites
- Node.js 18+
- Component toolchain:
@bytecodealliance/componentize-js(jco) or another WIT-compatible pipeline@alga-psa/extension-runtimehelpers for handlers/tests
- Familiarity with:
- TypeScript, React (for UI)
- Security best practices and least-privilege design
SDK and CLI Tools
The Alga SDK provides CLI tools and libraries to streamline extension development:
Installation
npm install -g @alga-psa/cli
# Or use npx: npx @alga-psa/cli <command>
CLI Commands
| Command | Description |
|---|---|
alga create extension <name> |
Scaffold a new extension project |
alga create component <name> |
Scaffold a WASM component project |
alga build |
Build the extension (compile TS → WASM if needed) |
alga pack |
Create bundle.tar.zst from built extension |
alga extension publish <dir> |
Build, pack, and publish to an Alga instance |
alga extension install <id> |
Install an extension from the registry |
alga extension uninstall <id> |
Uninstall an extension |
alga sign <bundle> |
Sign a bundle with cosign, x509, or pgp |
SDK Packages
| Package | Purpose |
|---|---|
@alga-psa/cli |
Command-line tools for building and publishing |
@alga-psa/client-sdk |
Programmatic APIs for build/pack/publish |
@alga-psa/extension-runtime |
TypeScript types and helpers for WASM handlers |
@alga/extension-iframe-sdk |
Host communication APIs for iframe UIs |
Project Layout (Example)
my-extension/
├── src/
│ ├── component/ # Component handler source (TS/JS + @alga-psa/extension-runtime)
│ │ ├── handler.ts
│ │ └── wit/ # Generated WIT/world bindings
│ ├── ui/ # Iframe app (Vite + React)
│ │ ├── index.html
│ │ └── src/
│ │ ├── main.tsx
│ │ └── components/
│ └── manifest.json # Manifest v2
├── dist/
│ ├── main.wasm # Componentized artifact produced by jco componentize
│ └── ui/ # Built iframe assets
├── SIGNATURE # Signature file (generated by CI)
├── package.json
└── README.md
Step-by-step: Build + Package an Extension
Quick Start with CLI
The fastest way to create and build an extension:
# Create a new extension project
alga create extension my-extension
cd my-extension
# Build (compiles TypeScript and creates WASM if needed)
alga build
# Create the bundle archive
alga pack
# Output: dist/bundle.tar.zst
# Publish to your Alga instance
alga extension publish . --api-key $ALGA_API_KEY --tenant $ALGA_TENANT_ID
Manual Setup
-
Scaffold directories
- Create
component/for your handler sources (e.g.,component/src/handler.ts) and copy the platform WIT definitions intocomponent/wit/extension-runner.wit. - Add
ui/for your static iframe app (any bundler). The host will serve files fromui/distinside the bundle. - Place
manifest.jsonat the repo root alongsidepackage.json.
- Create
-
Author the Wasmtime endpoint
- Install the toolchain:
npm install @alga-psa/extension-runtime @bytecodealliance/componentize-js. - Implement
handlerusing the runtime helpers:// component/src/handler.ts import { Handler, jsonResponse } from '@alga-psa/extension-runtime'; export const handler: Handler = async (req, host) => { const secret = await host.secrets.get('api_key'); const upstream = await host.http.fetch({ url: 'https://httpbin.org/get', headers: { authorization: `Bearer ${secret}` }, }); return jsonResponse({ ok: true, upstream: upstream.status }); }; - Compile to JavaScript (e.g.,
tscor Vite build) sodist/js/handler.jsexists. - Run
jco componentizeto produce the Wasmtime component:
npx jco componentize dist/js/handler.js --wit ./wit/extension-runner.wit --world-name runner --out dist/main.wasm
- Install the toolchain:
-
Add static UI assets
- Build your iframe application into
ui/dist(for example,npm run ui:buildproducingui/dist/index.html, JS/CSS chunks, images). - Reference the entry file from the manifest via
"ui": { "type": "iframe", "entry": "ui/dist/index.html" }.
- Build your iframe application into
-
Create or update
manifest.json- Include metadata, capabilities, and
assetsglobs:{ "name": "com.example.basic", "publisher": "Example Inc.", "version": "1.0.0", "runtime": "wasm-js@1", "capabilities": ["cap:http.fetch", "cap:secrets.get", "cap:log.emit", "cap:ui.proxy"], "ui": { "type": "iframe", "entry": "ui/dist/index.html" }, "assets": ["ui/dist/**/*"] } - Note:
api.endpointsis optional advisory metadata for documentation/tooling. It is NOT required for the proxy pattern to work.
- Include metadata, capabilities, and
-
Package the bundle
Using the CLI (recommended):
alga pack --project . # Creates dist/bundle.tar.zst with proper structureManual packaging:
- Layout your release directory:
dist/main.wasm ui/dist/**/* manifest.json precompiled/ (optional cwasm artifacts) - Create the canonical tarball (zstd recommended):
tar --zstd -cf bundle.tar.zst manifest.json dist/main.wasm ui/dist - Compute the content hash:
shasum -a 256 bundle.tar.zst→sha256:... - Produce
SIGNATURE(detached) with your publisher key per security_signing.md.
- Layout your release directory:
-
Publish & install
- Upload
bundle.tar.zst,manifest.json,SIGNATURE, and metadata via your CI/registry flow. - During tenant installation, operators provide install-scoped config and secrets that the gateway will pass to the runner.
- Upload
With these steps, the Runner can fetch your component, serve the static UI, and execute the Wasmtime handler when /api/ext/{extensionId}/... is invoked.
Manifest v2 (abridged)
See Manifest Schema for full details.
{
"name": "com.acme.my-extension",
"publisher": "Acme Inc.",
"version": "1.0.0",
"runtime": "wasm-js@1",
"capabilities": ["cap:http.fetch", "cap:storage.kv", "cap:secrets.get", "cap:ui.proxy"],
"ui": { "type": "iframe", "entry": "ui/index.html" },
"assets": ["ui/**/*"]
}
Key points:
ui.entrypoints to your iframe HTML within the bundle.cap:ui.proxyenables the postMessage proxy pattern for UI→WASM handler communication.capabilitiesrequest access to host features (http, storage, secrets, ui proxy, logging) and are granted per tenant install.api.endpointsis optional advisory metadata for documentation/tooling—not required for the extension to function.
Building Server Handlers (Componentized WASM)
- Write handlers in TypeScript (or JS) using
@alga-psa/extension-runtime, then runjco componentizeto producedist/main.wasm. - Optional: use other WIT-compatible languages (
wit-bindgenfor Rust/TinyGo) as long as the output conforms to thealga:extension/runnerworld. - Use host APIs (capability-scoped) exposed via the generated bindings:
host.http.fetch,host.storage.*,host.secrets.get,host.uiProxy.callRoute,host.logging.*.host.invoicing.createManualInvoice(requirescap:invoice.manual.create) for creating draft manual invoices.
Conceptual handler shape:
import { Handler, jsonResponse } from '@alga-psa/extension-runtime';
export const handler: Handler = async (request, host) => {
const items = await host.storage.list({
namespace: 'agreements',
limit: 50,
});
return jsonResponse(
{ data: items },
{ headers: { 'content-type': 'application/json' } }
);
};
Build example:
Using the CLI:
alga build --project .
# Automatically detects if WASM build is needed based on manifest
# Outputs: dist/main.wasm
Using npm scripts:
npm run build # compiles TS → JS
npm run build:component
# scripts should run:
# jco componentize dist/js/handler.js --wit ./wit/extension-runner.wit \
# --world-name runner --out dist/main.wasm
CI should output dist/main.wasm plus optional precompiled/ artifacts referenced by the manifest.
Building the Iframe UI
- Use the provided SDKs:
@alga/extension-iframe-sdkfor host communication (auth, theme, navigation, proxy calls)@alga/ui-kitfor accessible, consistent components
- Iframe bootstrap and URL construction are handled by the host via:
- UI assets are served by the Runner at
${RUNNER_PUBLIC_BASE}/ext-ui/{extensionId}/{content_hash}/[...]with immutable caching
Calling Your WASM Handler from the UI (postMessage Proxy Pattern)
Important: Extension UIs should NOT make direct fetch() calls to /api/ext/ endpoints. Instead, use the postMessage proxy pattern which ensures:
- Authentication is handled by the host
- Secrets never reach the browser
- Consistent error handling and timeouts
Using the iframe SDK:
import { IframeBridge } from '@alga/extension-iframe-sdk';
const bridge = new IframeBridge();
await bridge.ready();
// Call your WASM handler via the proxy
const response = await bridge.uiProxy.call('/agreements');
const data = JSON.parse(new TextDecoder().decode(response));
Or using the lower-level postMessage API (see client-portal-test sample):
// Send request to host
window.parent.postMessage({
alga: true,
version: '1',
type: 'apiproxy',
request_id: crypto.randomUUID(),
payload: { route: '/agreements' }
}, '*');
// Listen for response
window.addEventListener('message', (ev) => {
if (ev.data?.type === 'apiproxy_response') {
// Handle response...
}
});
The cap:ui.proxy capability is required in your manifest to use the proxy pattern.
Signing and Packaging
- Produce a content-addressed bundle including:
manifest.json- WASM artifacts under
dist/ - UI assets under
ui/ - Optional precompiled artifacts (cwasm) under
precompiled/
- Generate a SHA256 for the canonical bundle; sign with your publisher certificate; include
SIGNATURE - CI outputs an artifact ready for publish (e.g.,
bundle.tar.zst)
See Security & Signing.
Publish and Install
- Publish the bundle and metadata to the Registry
- Admin installs a version for a tenant and grants capabilities
- The app server does not store or execute tenant code; bundles are verified and referenced via
content_hash
Calling Your Handlers (Gateway)
- Host exposes
/api/ext/[extensionId]/[[...path]]. - Gateway resolves install → version/content hash → config/providers/sealed secrets and calls Runner
POST /v1/executewith that metadata. - Header/time/size policies are enforced by the Gateway and Runner.
- Manifest endpoint lists are used for documentation/UX; strict enforcement is under active discussion (see plan).
Reference route scaffold: server/src/app/api/ext/[extensionId]/[[...path]]/route.ts
Local Development Tips
For a detailed walkthrough of local development setup, build, install, and debugging workflows, see Local Development Guide.
Quick reference:
- Use the pluggable runner backend to switch between Knative (
RUNNER_BACKEND=knative) and the local Docker runner (RUNNER_BACKEND=docker).- Start the runner container:
npm run runner:upordocker compose -f docker-compose.runner-dev.yml up(stops withnpm run runner:down). - Launch the app with Docker backend defaults:
npm run dev:runner(setsRUNNER_DOCKER_HOST=http://localhost:${RUNNER_DOCKER_PORT:-8085}and proxies UI assets through/runner/*). - Install your extension locally:
node scripts/dev-install-extension.mjs ./path/to/extension. - Override runner URLs via
.env.runner(see.env.runner.example) if you are using alternative S3 or registry endpoints.
- Start the runner container:
- Keep UI bundles small; leverage code splitting where appropriate
- Use the iframe SDK's theme APIs to adapt to host styling
- Validate endpoint inputs/outputs and include clear error responses
Best Practices
- Least privilege: request only necessary capabilities
- Input validation and resource bounds: avoid large responses; paginate/filter server-side
- Emit structured logs/metrics via Host API to support observability
- Prefer deterministic builds; maintain SBOMs; update dependencies proactively
References
- Local Development Guide — Complete setup and workflow for developing locally with Docker Runner
- Manifest Schema
- API Routing Guide
- Security & Signing
- Runner
- Sample Extension
- Iframe bootstrap and src builder: server/src/lib/extensions/ui/iframeBridge.ts