Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,44 @@ func main() {

## Building for Paca

Paca backend plugins must be compiled to WebAssembly using the WASI preview 1 target (`wasip1`).
Paca backend plugins must be compiled to WebAssembly using the WASI preview 1 target (`wasip1`), with TinyGo:

```bash
GOARCH=wasm GOOS=wasip1 go build -o plugin.wasm main.go
tinygo build -target=wasip1 -buildmode=c-shared -o plugin.wasm main.go
```

> **Note**: Using [TinyGo](https://tinygo.org/) is strongly recommended for producing smaller WASM binaries (often < 1MB vs ~10MB with standard Go).
`-buildmode=c-shared` is required, not optional: without it, TinyGo doesn't
wire up the WASI reactor entry point (`_initialize`) that this SDK's
runtime depends on, and every call into the plugin panics at runtime with
`"//go:wasmexport function called before runtime initialization"` — it
still compiles fine without the flag, so this only surfaces once the
plugin is actually loaded by the host.

TinyGo is strongly recommended over the standard Go compiler: it doesn't
statically link Go's full runtime (GC, scheduler, reflection), so a typical
plugin binary is well under 1MB versus several MB with standard Go — and
since every loaded plugin gets its own independent copy of that runtime on
the host, this is a real difference in the host's memory use per plugin,
not just download size.

Standard Go 1.24+ also works (`GOARCH=wasm GOOS=wasip1 go build
-buildmode=c-shared -o plugin.wasm main.go`) and is the right fallback if
your plugin hits a TinyGo compatibility limitation — TinyGo implements a
subset of the stdlib, notably around `reflect`. One TinyGo-specific
constraint either way: if your plugin declares its own `//go:wasmimport`
host function beyond what this SDK provides (see [`CallHostFunction`](./custom_host_call.go)),
call it directly by name rather than passing it as a value — TinyGo
doesn't support taking the address of a `go:wasmimport` function, only
calling it. If you use `CallHostFunction`, wrap your import in a small
closure:

```go
// Works under both standard Go and TinyGo:
err := plugin.CallHostFunction(func(a, b, c, d int64) { hostMyImport(a, b, c, d) }, req, &res)

// Compiles under standard Go, but fails to build under TinyGo:
err := plugin.CallHostFunction(hostMyImport, req, &res)
```

## API Reference

Expand Down
15 changes: 13 additions & 2 deletions wasm_backends.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,19 @@ import (
// ── Memory management ─────────────────────────────────────────────────────────

// mallocBuffer is a pre-allocated buffer for host-managed memory allocations.
// The host writes request data into this buffer and reads response data from it.
var mallocBuffer [10 * 1024 * 1024]byte
// The host writes request data into this buffer, and the response is later
// packed into the same space — never both at once: wasm_exports.go resets
// the allocator between reading the request and allocating the response, so
// each phase gets the buffer's full capacity rather than splitting it. Sized
// to match services/api's DefaultResourceLimits.MaxRequestBodyBytes
// (paca/services/api/internal/platform/plugin/runtime.go) — the host already
// rejects any payload larger than that before ever attempting to write it
// here, so this only needs to cover the same ceiling, not more. This buffer
// is a fixed array (not a growable slice) because mallocBase below caches its
// linear-memory address once; a slice that reallocates on growth would go
// stale and corrupt every pointer handed to the host afterward. Keep this in
// sync with MaxRequestBodyBytes if either changes.
var mallocBuffer [1 * 1024 * 1024]byte
Comment thread
pikann marked this conversation as resolved.
Resolved

// mallocOffset tracks the next free position in mallocBuffer.
var mallocOffset int32
Expand Down
23 changes: 19 additions & 4 deletions wasm_exports.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,29 @@
// Package plugin — WASM export layer.
//
// This file provides the four exported functions that the paca host runtime
// expects: Init, HandleRequest, HandleEvent, Shutdown, plus malloc/free for
// host-managed memory allocation.
// expects: Init, HandleRequest, HandleEvent, Shutdown, plus paca_malloc/
// paca_free for host-managed memory allocation.
package plugin

// ── Memory management ─────────────────────────────────────────────────────────

// Exported as paca_malloc/paca_free, not malloc/free: TinyGo's own bundled
// wasi-libc allocator already exports functions literally named "malloc" and
// "free", so exporting under those same names produces a WASM module with
// duplicate export names — invalid per spec, and rejected at load time. The
// host's export lookup (services/api/internal/platform/plugin/runtime.go)
// must use these same names.

// nolint // exported function used by host runtime via WASM interface
//
//go:wasmexport malloc
//go:wasmexport paca_malloc
func malloc(size int32) int32 {
return wasmMalloc(size)
}

// nolint // exported function used by host runtime via WASM interface
//
//go:wasmexport free
//go:wasmexport paca_free
func free(_ int32) {}

// ── Exported WASM functions ───────────────────────────────────────────────────
Expand All @@ -41,6 +48,12 @@ func HandleRequest(ptr, length int32) int64 {
}
payload := wasmSlice(ptr, length)
result := globalDispatcher.handleRequest(payload)
// handleRequest fully consumes payload via unmarshalJSON before doing
// anything else, so the request bytes are no longer needed by the time
// it returns. Reclaim the whole arena for the response instead of
// stacking the response after the request — otherwise a request near
// the arena's capacity would leave no room to allocate the response.
wasmResetAllocator()
return packWASMResult(result)
}

Expand All @@ -56,6 +69,7 @@ func EvaluateCondition(ptr, length int32) int64 {
}
payload := wasmSlice(ptr, length)
result := globalDispatcher.evaluateCondition(payload)
wasmResetAllocator() // see HandleRequest: payload is fully consumed by this point
return packWASMResult(result)
}

Expand All @@ -66,6 +80,7 @@ func RunAction(ptr, length int32) int64 {
}
payload := wasmSlice(ptr, length)
result := globalDispatcher.runAction(payload)
wasmResetAllocator() // see HandleRequest: payload is fully consumed by this point
return packWASMResult(result)
}

Expand Down
Loading