Skip to content

feat(plugins/runtime): wazero-backed WASM runtime foundation (#6)#350

Merged
tayebmokni merged 1 commit into
mainfrom
feat/6-wazero-runtime
May 17, 2026
Merged

feat(plugins/runtime): wazero-backed WASM runtime foundation (#6)#350
tayebmokni merged 1 commit into
mainfrom
feat/6-wazero-runtime

Conversation

@tayebmokni
Copy link
Copy Markdown
Contributor

Summary

Closes #6.

What's NOT here (separate issues)

The package's doc.go documents the seams each of those plugs into.

Test plan

  • go vet ./plugins/runtime/... clean
  • go test -race -count=1 ./plugins/runtime/... — 17 tests pass
  • Happy path: add(7, 35) == 42 after LoadModule
  • Guest panic via gn_panic surfaces as *TrapError carrying
    the guest message
  • Malformed bytes → *CompileError (no host panic, no leak)
  • Concurrent calls: 16 goroutines × 64 calls on one module under
    -race, all return correct results
  • Memory cap: a module declaring (memory 1024) (64 MiB) is
    rejected by the 256-page (16 MiB) runtime cap with a clean
    error (no panic)
  • Close is idempotent; in-flight Call drains before close
    returns
  • WithHostModule builder is invoked and propagates errors

Notes for reviewers

The hand-authored WASM bytes in testdata_test.go are annotated
section-by-section. The matching human-readable .wat sources live
in plugins/runtime/wat/ alongside a Makefile that rebuilds them
when wat2wasm is installed locally. The committed .wasm bytes
are the source of truth so go test has no on-disk dependency.

gn_panic surfaces guest messages via a package-level recorder map
keyed by module name. This is the simplest way to get a custom
payload out of a host function that has to close the module to
trigger the trap. The mechanism is documented in module.go
(classifyCallError) and host.go (hostGnPanic).

🤖 Generated with Claude Code



Adds packages/go/plugins/runtime/ as the foundation layer for the
GoNext plugin host. Wraps tetratelabs/wazero@v1.11.0 with a small,
opinionated API:

  - Runtime — owns the wazero runtime, registers the built-in "env"
    host module, hands out Module instances.
  - Module — instantiated WASM module; goroutine-safe Call (per-module
    mutex serializes wazero's non-thread-safe Function.Call).
  - host.go — registers gn_log, gn_panic, gn_time_ms. gn_panic stashes
    its decoded reason then closes the module so the trap surfaces as
    a *TrapError with the guest's message.
  - WithHostModule extension seam so the capability ABI (#107) can
    register additional host modules without touching this package.
  - 256-page (16 MiB) per-module memory cap as the v1 placeholder
    until the real resource-limits story (#15) lands.

Tests cover: happy-path Call (add(7,35)=42), guest gn_panic surfaces
as TrapError with the panic reason, malformed bytes -> CompileError
(no panic leak), memory cap rejects oversized modules cleanly,
concurrent calls into the same module from 16 goroutines x 64 calls
under -race, Close-during-Call drains in-flight invocations.

Pool (#9), real limits (#15), and the capability ABI (#107) build on
this — none are in scope for this PR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
@tayebmokni tayebmokni merged commit a6dc560 into main May 17, 2026
8 checks passed
@tayebmokni tayebmokni deleted the feat/6-wazero-runtime branch May 17, 2026 19:34
tayebmokni added a commit that referenced this pull request May 17, 2026
…356)

## Summary
- New `packages/go/plugins/runtime/limits` package: `Limits{MemoryPages,
CPUTimeoutSoft, CPUTimeoutHard, MaxInstancesPerPlugin}`, `Default()`,
`Validate()`, `Enforcer` (CPU deadline wrapping + per-plugin instance
Acquire/Release).
- Runtime gains `WithLimits(l Limits)` option and `Enforcer()` accessor;
every `Module.Call` now applies the enforcer's soft/hard deadline pair
to the call context. Wazero's `WithCloseOnContextDone(true)` (already
set in #350) turns cancellation into a guest trap.
- `tightloop.wat` fixture + hand-encoded binary so CPU-deadline tests
have a guest with no natural exit path.

## Test plan
- [x] `go vet ./plugins/runtime/limits/...` — clean
- [x] `go test -race -count=1 ./plugins/runtime/limits/...` — green
(validation, soft+hard timer fire ordering, concurrent Acquire under
-race, idempotent release, per-name counters)
- [x] `go test -race -count=1 ./plugins/runtime/...` — green (soft
timeout traps tight loop ~100ms, hard timeout-only path also traps, fast
call with zero limits stays fast, memory cap below `bigmem`'s 1024 pages
rejects load, default envelope kills a tight loop within 5s)
- [x] Invalid Limits (hard < soft) rejected at `New()` time

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
tayebmokni added a commit that referenced this pull request May 17, 2026
… (#357)

## Summary

- Adds `packages/go/plugins/runtime/pool/` — a fixed-bounded pool of
pre-warmed wazero modules so per-request plugin calls (filter hooks,
REST handlers) skip the 1–5 ms instantiation tax.
- `Pool.Checkout(ctx) -> Lease` blocks up to the caller's deadline;
`Lease.Return()` recycles or restocks; `Lease.MarkUnusable()` retires a
trapped instance.
- Reaper goroutine evicts idle instances past `MaxIdleTime` and refills
back to `MinInstances`. `MaxUsesPerInstance` rotates aging instances to
prevent linear-memory bloat.
- Prometheus surface: `checkout_total`, `checkout_errors_total`,
`checkout_wait_seconds`,
`recycle_total{reason=trap|max_uses|idle|close}`, `pool_size`, `in_use`
— all under `gonext_plugin_pool_*`.
- Builds on PR #350's wazero `Runtime` / `Module` shapes without
modifying them.

## Test plan

- [x] `go vet ./plugins/runtime/pool/...` clean
- [x] `go test -race -count=1 ./plugins/runtime/pool/...` green (full
suite ~6 s)
- [x] Checkout/Return cycle
- [x] Concurrent checkouts saturate at `MaxInstances`; (Max+1)th blocks
until Return
- [x] `ctx.Deadline` timeout while blocked — wrapped
`ErrCheckoutTimeout` + `context.DeadlineExceeded`, no leak
- [x] `MaxUsesPerInstance` rotates the underlying module pointer after N
uses
- [x] `MaxIdleTime` reaper evicts idle and refills to `MinInstances`
- [x] Trap-flagged lease forces recycle on Return, never re-enters
rotation
- [x] `Pool.Close` drains outstanding leases; honors ctx deadline;
second Close is no-op
- [x] Race test: 100 goroutines × 1000 checkouts under `-race` — zero
races, zero leaks

Closes #9.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
tayebmokni added a commit that referenced this pull request May 17, 2026
## Summary

Bridges the host-side action+filter bus (`packages/go/hooks`, PR #318)
to plugin WebAssembly modules loaded by the wazero runtime
(`packages/go/plugins/runtime`, PR #350). At activation time the
lifecycle Manager walks the manifest's `hooks` declarations; for each
declared hook the bridge installs a callback on the host bus that
proxies through to the plugin's `gn_handle_hook` export.

Closes #95.

## The ABI

Every plugin exports:

```
(func (export "gn_handle_hook")
      (param $name_ptr i32) (param $name_len i32)
      (param $payload_ptr i32) (param $payload_len i32)
      (result i64))

(func (export "gn_alloc") (param i32) (result i32))   ;; bump or arena allocator
(func (export "gn_free")  (param i32 i32))            ;; counterpart
```

The packed `i64` return is `ptr<<32 | len`. Special cases:

- `(0, 0)` — success, no body (action return)
- `(0, negative)` — typed `ResultStatus`: `error` / `out_of_memory` /
  `bad_payload` / `unknown_hook`
- `(ptr, len>0)` — success with body at `[ptr, ptr+len)` in the guest's
  exported memory; the host copies and then calls `gn_free`

Payloads are JSON envelopes (`{"kind":"action","args":[…]}` or
`{"kind":"filter","value":…,"args":[…]}`); filter results are
`{"value":…}`.

## What's here

- `abi.go` — contract documentation, `ResultStatus` catalog,
  `HookError` type with `errors.Is` matrix across sentinels
- `marshal.go` — JSON envelopes for action and filter payloads
- `dispatcher.go` — `Dispatcher` drives the ABI on a wazero `Module`
  (`InvokeAction` / `InvokeFilter`); caches export lookups; surfaces
  traps via `ResultStatusTrap`
- `registry.go` — `Bridge` walks `manifest.Hooks`, installs proxy
  callbacks on the host bus; `Unregister` detaches them all
- `wat/the_content.wat` — fixture plugin: `the_content` filter
  uppercases its input; `on_event` action returns OK-no-body;
  `trip_panic` action calls `gn_panic` for trap testing. Includes a
  bump allocator and a `force_oom()` test helper

## What's NOT here (separate issues)

- Lifecycle wiring (Manager constructing the Bridge per activation) —
  the lifecycle Manager doesn't yet pull in this package; that wiring
  belongs to the activation flow refactor
- Per-hook priority hints from the manifest — placeholder `PriorityFunc`
  hook in `NewBridge` until the manifest schema gains a `priority`
  field
- ABI v2 (e.g. msgpack for hot-path hooks) — versioned via sibling
  package per the contract notes

## Test plan

- [x] `go vet ./plugins/abi/hooks/...` clean
- [x] `go test -race -count=1 ./plugins/abi/hooks/...` — 17 tests pass
- [x] Filter happy path: `"hello"` → `"HELLO"` through `the_content`
- [x] Action with no payload (empty action) — guest accepts empty args
- [x] Action with args (string, int, map) marshals and dispatches
- [x] Unknown hook → `ErrUnknownHook` (typed status)
- [x] Plugin traps → `ErrTrapped`, underlying `*TrapError` unwrappable
- [x] Guest OOM (bump pointer past memory end) → `ErrOutOfMemory`
- [x] Host payload cap (>1 MiB) → `ErrPayloadTooLarge` before any guest
call
- [x] 100 concurrent invocations on the same Module: all correct,
      race detector clean
- [x] `Bridge.Register` walks manifest, `bus.Do` and `bus.ApplyFilters`
      reach the plugin, `Bridge.Unregister` detaches cleanly
- [x] Missing ABI exports surface as `ErrMissingExport`
- [x] `HookError` `errors.Is` against `ErrOutOfMemory`, `ErrBadPayload`,
      `ErrUnknownHook`, `ErrGuestError`, `ErrTrapped`

## Notes for reviewers

The fixture `.wasm` is committed as a generated Go slice
(`fixturedata_test.go`) because the repo `.gitignore` excludes
`*.wasm`. The `.wat` source plus a Python encoder
(`wat/encode.py`) and Makefile let reviewers with `wat2wasm`
regenerate in one command: `make -C wat`.

The filename dodges two Go-tool quirks:

- anything with a `testdata` prefix is ignored as test data
- anything matching `*_wasm.go` or `*_wasm_test.go` is interpreted as
  `GOOS=wasm`-only

`fixturedata_test.go` avoids both. There's a comment in `abi_test.go`
explaining this for future archaeologists.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
tayebmokni added a commit that referenced this pull request May 18, 2026
…loses #245 (#371)

## Summary

Closes #245. Adds the `gonext plugin dev <project-dir>` subcommand for
plugin authors: auto-detect the toolchain, build to WASM, upload to a
running dev host, watch for changes, and hot-reload. Lives in
`cli/gonext/cmd/plugin/`.

Foundation merged so the plugin platform now has: wazero runtime (#350),
manifest validator (#345), capability registry (#344), instance pool
(#357), resource limits (#356), hook ABI (#364), lifecycle (#339). This
PR is the productive author-side dev loop on top of all that.

### Flags

```
gonext plugin dev [flags] <project-dir>

  --host         URL of the running gonext dev host (default http://localhost:8080)
  --watch        Watch the project tree and hot-reload on change (default true)
  --build-only   Build only; skip upload and watch
  --lang         auto | go | tinygo | rust (default auto)
```

### Architecture

The orchestrator (`runDevLoop`) runs four phases — detect → build →
upload → watch — and every I/O dependency funnels through an injectable
seam:

| Seam | Production | Test |
|---|---|---|
| `CommandRunner` | `execRunner` (exec.CommandContext) | `fakeRunner`
recording argv + optional Hook to plant artifacts |
| `Uploader` | `httpUploader` (multipart POST) | `stubUploader` counter
/ `httptest.Server` |
| `Watcher` (factory) | `fsnotifyWatcher` (recursive Add) |
`fakeWatcher` (buffered chans) |
| `Now func()` | `time.Now` | frozen clock |

That lets the watch+rebuild loop be unit-tested end-to-end without
forking, real filesystem races, or network round-trips. 64 test cases
across six files.

### Per-language build commands

| Language | Detection | Command | Output |
|---|---|---|---|
| TinyGo | `go.mod` present | `tinygo build -o build/plugin.wasm
-target=wasi .` | `build/plugin.wasm` |
| Rust | `Cargo.toml` present | `cargo build --target wasm32-wasi
--release` | Copied from `target/wasm32-wasi/release/*.wasm` to
`build/plugin.wasm` |

Both markers present produces an ambiguity error rather than a silent
pick. The Rust path errors if cargo produces more than one `.wasm` (asks
the author to set `[lib].name`).

### Watch loop

- `fsnotify` recursively registers the project tree at startup, skipping
`build/`, `target/`, `node_modules/`, `.git/`, `.idea/`, `.vscode/`,
`vendor/`, `dist/`, `__pycache__/`.
- Generic `debounce[T any]` with a 200ms trailing-edge window collapses
bursty inotify events (single save → single rebuild). Tested with
20–30ms windows and synthetic event sources.
- Editor tempfiles (`*~`, `.#*`) and our own `build/` writes are
filtered so the loop doesn't feed itself.
- First-pass build/upload errors propagate out (non-zero exit). Errors
during watch are printed but the loop keeps running — the operator's
next save is probably the fix.
- SIGINT / SIGTERM → graceful exit 0.

### Capability diff

After each successful build the manifest's `capabilities` list is
compared against the previous snapshot:

```
[19:11:29] detected language: tinygo
[19:11:29] build: tinygo
[19:11:29] upload: http://localhost:8080
capabilities:
  = http.fetch
  = db.read
[19:11:30] uploaded successfully
[19:11:30] watching /path/to/plugin (Ctrl-C to stop)
[19:11:42] change detected — rebuilding
capabilities changed:
  + media.write
  - db.read
```

This is what gives the operator a clear before/after view between hot
reloads — the capability set is what the host is being asked to grant.

## TODO — host-side endpoint follow-up

This CLI uploads to `POST ${host}/_/plugins/dev/install` but the
host-side endpoint is **not in this PR**. The contract this CLI assumes:

- Path: `/_/plugins/dev/install`
- Method: `POST`
- Content-Type: `multipart/form-data`
- Parts:
- `manifest` — file part, `application/json`, the `manifest.json` bytes
  - `wasm` — file part, `application/wasm`, the compiled module
- Success: any 2xx (body ignored)
- Failure: any non-2xx; body's first 1KiB is included verbatim in the
CLI's error message

The natural host-side implementation calls the existing lifecycle
Manager's `Install(ctx, bundle io.Reader)` — that function already
validates the manifest and handles slug/version/abi extraction. The dev
install endpoint would also need to bypass signature verification
(dev-only) and idempotently re-activate on every upload. Followup issue
suggested: \"feat(server): host-side dev plugin install endpoint\".

## Test plan

- [x] `cd cli/gonext && go vet ./...` — clean
- [x] `cd cli/gonext && go test -race -count=1 ./cmd/plugin/...` — 64
cases pass under `-race`
- [x] `cd cli/gonext && go test -race -count=1 ./...` — entire CLI
module green
- [x] `cd cli/gonext && go build ./...` — builds
- [x] Language detector: tinygo via `go.mod`, rust via `Cargo.toml`,
ambiguous error when both present, missing-marker error, dir-not-file
rejected
- [x] Build orchestrator: TinyGo argv = `[build -o
<abs>/build/plugin.wasm -target=wasi .]`, Rust artifact normalisation,
multiple `.wasm` error path, runner failure propagates
- [x] Multipart upload: httptest server roundtrip verifies path,
Content-Type, both parts, byte fidelity; host trailing-slash + subpath
join correctly; 4xx body preview surfaces; missing wasm errors; relative
host rejected
- [x] Debounce: 5-event burst collapses to 1 emission; two bursts 60ms
apart fire twice; ctx-cancel closes out-channel; input-close closes
out-channel
- [x] Capability diff: first build prints `=` list, no-change emits
nothing, add/remove deltas use `+`/`-`
- [x] Dispatcher: `--build-only` skips upload + watch, watch+rebuild on
event, watch keeps running through build error and recovers on next
event, usage/help/missing-arg/extra-arg/not-a-directory exit codes
- [ ] Manual smoke against a running dev host — gated on the host-side
endpoint follow-up

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
tayebmokni added a commit that referenced this pull request May 18, 2026
…#271 (#387)

## Summary
- New `packages/go/plugins/debug` package: Source Map V3 parser, trap
inspector with `<no source map>` graceful degrade, and a pub/sub
`LogHub` + WebSocket handler at `/_/plugins/dev/logs/{plugin}`.
- Runtime gains a `LogPublisher` seam (`WithLogPublisher`); `hostGnLog`
forwards every `gn_log` call after the slog write, non-blocking.
- `gonext plugin dev --logs` opens the WebSocket and prints color-coded
log lines; client-side RFC 6455 handshake inline, no third-party
websocket deps.

## Test plan
- [x] `go vet ./plugins/debug/...` clean
- [x] `go test -race -count=1 ./plugins/debug/...` — parser, inspector
with/without map, hub fanout/filter/cancel/lag, WS round-trip, bad-path
400
- [x] `go test -race -count=1 ./plugins/runtime/...` — new
`TestHost_GnLog_Publisher` plus full existing suite
- [x] `go test -race -count=1 ./cli/gonext/cmd/plugin/...` — `--logs`
URL builder, color mapping, CLI tailer round-trip via httptest WS
upgrader, no-flag skip
- [x] DCO sign-off included; no `--no-verify`, no amend

Builds on #350 (Wazero runtime) and #371 (`gonext plugin dev`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
tayebmokni pushed a commit that referenced this pull request May 18, 2026
Introduces packages/go/plugins/abi/jobs, the bridge between a plugin's
manifest jobs[] declarations and the host TaskSpec + Asynq dispatch
chassis. Mirrors the hooks bridge (abi/hooks, #350/#364) but for queued
background jobs.

The bridge:

  - Defines the gn_handle_job ABI (abi.go) with the same packed-i64
    return shape as gn_handle_hook so plugin SDKs share most of their
    host-call plumbing.
  - Provides a Dispatcher (dispatcher.go) that drives the ABI on a
    plugin Module — serialised allocation, name+payload write,
    invocation, packed-result unpack, typed JobError on failure.
  - Provides a Bridge (bridge.go) that walks manifest.Jobs and
    registers one taskspec.TaskSpec per declared job, routed to the
    "plugin" Asynq queue (per #362), with the asynq Task ID threaded
    through the envelope as an idempotency key (per #363).
  - Gates registration on the jobs.enqueue capability via the
    plugin capabilities Checker (#356/#344). Manifests declaring
    jobs without the cap are rejected before any TaskSpec installs.

Hot-reload contract: Unregister flips the closed flag so the installed
TaskSpec.Handler short-circuits with a retryable error; the lifecycle
Manager constructs a fresh Bridge against a fresh registry on plugin
reload.

Tests (22 cases, race-clean):

  - Pack/unpack round-trip and envelope marshal shapes.
  - Happy-path invocation, unknown job, trap, OOM, payload too large.
  - Bridge registers from manifest with correct queue/MaxRetry.
  - Capability denial rejects registration with no half-wired state.
  - Trap inside job returns retryable error (no asynq.SkipRetry).
  - Hot reload: gen1 unregister + gen2 register on a fresh registry.
  - Duplicate job name surfaces taskspec.ErrAlreadyRegistered.
  - 100 concurrent task pickups for the same plugin (race detector).

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
tayebmokni added a commit that referenced this pull request May 18, 2026
## Summary

- Introduces `packages/go/plugins/abi/jobs` — the bridge between a
plugin's manifest `jobs[]` declarations and the host TaskSpec + Asynq
dispatch chassis (#355, #362). Mirrors the hooks bridge (#350, #364) for
queued background jobs.
- Defines the `gn_handle_job(name_ptr, name_len, payload_ptr,
payload_len) -> i64` ABI with the same packed-i64 return shape as
`gn_handle_hook`, so plugin SDKs share most of their host-call plumbing.
- For each `manifest.Jobs` entry, registers a `taskspec.TaskSpec` routed
to the `plugin` queue, with the asynq Task ID threaded through the
envelope as an idempotency key (#363). Capability-gated on
`jobs.enqueue` via the plugin capabilities Checker (#356, #344) —
manifests declaring jobs without the cap are rejected before any
TaskSpec installs.

## Test plan

- [x] `go vet ./plugins/abi/jobs/...` clean.
- [x] `go test -race -count=1 ./plugins/abi/jobs/...` — 22 cases pass:
pack/unpack round-trip, envelope marshal shapes,
happy-path/unknown/trap/OOM/oversize, manifest registration with correct
queue + MaxRetry, capability denial leaves the registry untouched, trap
returns a retryable error (no `asynq.SkipRetry`), hot-reload across two
fresh registries, duplicate job surfaces
`taskspec.ErrAlreadyRegistered`, and 100 concurrent task pickups for the
same plugin.
- [x] Full `./plugins/... ./jobs/...` test suite stays green.
- [x] DCO sign-off present.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
tayebmokni pushed a commit that referenced this pull request May 19, 2026
…-to-end — closes #269

## Summary

- `examples/plugins/seo/` — a TinyGo-built plugin that ships
  `manifest.json` (apiVersion gonext.io/v1), four exported ABI symbols
  (`gn_alloc`, `gn_free`, `gn_handle_hook`, `gn_handle_job`), and a
  build script. Subscribes to `the_content` (filter, injects
  schema.org JSON-LD), `wp_head` (action, emits `<title>` + meta +
  OpenGraph + Twitter card), and `save_post` (action, computes SEO
  score). Owns one background job (`seo.recompute-scores`).
- `domain.go` — pure Go helpers (`BuildTitle`, `BuildDescription`,
  `BuildHeadHTML`, `BuildJSONLD`, `ComputeSEOScore`) split out so the
  test suite exercises the same code the WASM blob links — no
  TinyGo required to run `go test`.
- `dummy_host_test.go` — Go-side fake host bus that mirrors the
  wazero dispatcher path, proving the wire format the plugin speaks
  matches what `packages/go/plugins/abi/hooks/marshal.go` produces.
  Canonical copy at `packages/go/plugins/internal/_seo_dummy.go`.
- `seo_test.go` — manifest validates against
  `packages/go/plugins/manifest/schema.json`; unit tests for all
  domain functions; HTML escaping; JSON-LD parses + carries Article
  required fields; pinned-score table; end-to-end filter run.
- `docs/04-seo-plugin-tutorial.md` — a 30-minute "build this plugin
  from scratch" walkthrough.
- `go.work` — adds `./examples/plugins/seo` so the example builds
  alongside the workspace; the example has its own `go.mod` so
  TinyGo doesn't drag in the host's Postgres/Redis/Asynq deps.

The plugin uses every plumbed capability (`posts.read`,
`posts.write`, `hooks.subscribe`, `jobs.enqueue`) so the install
dialog has a real-world cap list to render.

## Test plan

- [x] `cd /tmp/gn-i11 && go test -race -count=1 ./examples/plugins/seo/...` — 14/14 pass
- [x] `cd packages/go && go test -race -count=1 ./plugins/lifecycle/... ./plugins/abi/hooks/...` — pass
- [x] `cd packages/go && go test -race -count=1 ./plugins/manifest/...` — pass
- [x] `cd examples/plugins/seo && bash build.sh` — exits with helpful "tinygo not on PATH" message when toolchain missing; documented in README
- [ ] Manual: install TinyGo, run `./build.sh`, pack into `seo.gnplugin` via the documented zip recipe, install + activate via the lifecycle Manager (deferred — requires admin UI from #350)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
tayebmokni added a commit that referenced this pull request May 19, 2026
…-to-end — closes #269 (#403)

## Summary

- `examples/plugins/seo/` — a TinyGo-built plugin that ships
`manifest.json` (apiVersion `gonext.io/v1`), four exported ABI symbols
(`gn_alloc`, `gn_free`, `gn_handle_hook`, `gn_handle_job`), and a build
script. Subscribes to `the_content` (filter, injects schema.org
JSON-LD), `wp_head` (action, emits `<title>` + `<meta>` + OpenGraph +
Twitter card), and `save_post` (action, computes SEO score). Owns one
background job (`seo.recompute-scores`).
- `domain.go` — pure Go helpers (`BuildTitle`, `BuildDescription`,
`BuildHeadHTML`, `BuildJSONLD`, `ComputeSEOScore`) split out so the test
suite exercises the same code the WASM blob links — no TinyGo required
to run `go test`.
- `dummy_host_test.go` — Go-side fake host bus that mirrors the wazero
dispatcher path, proving the wire format the plugin speaks matches what
`packages/go/plugins/abi/hooks/marshal.go` produces. Canonical copy at
`packages/go/plugins/internal/_seo_dummy.go` (leading underscore so Go
skips it for builds; doc-only).
- `seo_test.go` — manifest validates against
`packages/go/plugins/manifest/schema.json`; unit tests for all domain
functions; HTML escaping; JSON-LD parses + carries Article required
fields; pinned-score table; end-to-end filter run.
- `docs/04-seo-plugin-tutorial.md` — a 30-minute "build this plugin from
scratch" walkthrough that mirrors the implementation file by file.
- `go.work` — adds `./examples/plugins/seo` so the example builds
alongside the workspace; the example has its own `go.mod` so TinyGo
doesn't drag in the host's Postgres/Redis/Asynq deps.

Capability rationale (the plugin uses every plumbed capability so the
install dialog has a real-world cap list to render):

| Capability | Why it's needed |
| ----------------- |
------------------------------------------------------------ |
| `posts.read` | Read the post being saved when computing the SEO score.
|
| `posts.write` | Persist score to `_seo_score` post meta (via host
call). |
| `hooks.subscribe`| Required to register the three hook subscriptions.
|
| `jobs.enqueue` | Required to enqueue `seo.recompute-scores`. |

## Test plan

- [x] `go test -race -count=1 ./examples/plugins/seo/...` — 14 tests
pass (manifest schema, unit tests, JSON-LD validity, score table,
end-to-end filter)
- [x] `cd packages/go && go test -race -count=1 ./plugins/lifecycle/...
./plugins/abi/hooks/...` — pass (runtime regression check per the brief)
- [x] `cd packages/go && go test -race -count=1 ./plugins/manifest/...`
— pass
- [x] `bash examples/plugins/seo/build.sh` — exits with a helpful
"tinygo not on PATH" message when toolchain missing; README documents
the install URL
- [ ] Manual: install TinyGo, run `./build.sh`, pack into `seo.gnplugin`
via `zip -j seo.gnplugin manifest.json seo.wasm`, install + activate via
the lifecycle Manager (deferred — requires the admin install dialog from
#350)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin host: wazero runtime + manager + compiled module cache

2 participants