Detailed architecture guide for how runtime-scoped HashiCorp plugin support is implemented inside go-go-goja.
This page is for developers working on the plugin subsystem itself rather than just using it.
The most important architectural fact is this: plugin support in go-go-goja is host-owned runtime composition, not remote JavaScript execution. The host process owns goja.Runtime, require.Registry, runtime lifecycle, and module registration. Plugin subprocesses provide discovery metadata and per-call execution over RPC, but they do not own the JavaScript VM.
That design choice is what keeps the rest of the system coherent. It lets existing require() semantics stay in the host, lets plugin modules look like ordinary native modules to JavaScript callers, and ensures cleanup happens through the same runtime lifecycle as any other runtime-owned resource.
If you are new to the subsystem, read these files in this order:
pkg/engine/runtime_modules.gopkg/engine/factory.gopkg/engine/runtime.gopkg/hashiplugin/sdk/module.gopkg/hashiplugin/sdk/export.gopkg/hashiplugin/sdk/call.gopkg/hashiplugin/sdk/dispatch.gopkg/hashiplugin/sdk/serve.gopkg/hashiplugin/contract/jsmodule.protopkg/hashiplugin/shared/plugin.gopkg/hashiplugin/host/config.gopkg/hashiplugin/host/discover.gopkg/hashiplugin/host/client.gopkg/hashiplugin/host/validate.gopkg/hashiplugin/host/reify.gopkg/hashiplugin/host/registrar.gopkg/hashiplugin/host/registrar_test.gopkg/repl/evaluators/javascript/evaluator.gocmd/goja-repl/root.gocmd/goja-repl/tui.goThat order mirrors the real layering:
Before this work, the engine built one require.Registry up front and had no general runtime-scoped module-registration seam plus no general runtime cleanup hook for external resources. That made plugin support awkward because plugins want both:
If plugin clients had been attached to the factory instead, subprocess lifetime would have drifted away from runtime lifetime. That would have made runtime reuse and shutdown behavior confusing and fragile.
The current plugin architecture has five layers:
plugin authoring code
|
v
author-facing sdk
|
v
CLI / evaluator config
|
v
engine runtime builder
|
v
runtime module registrar
|
v
plugin host package
|
v
go-plugin gRPC transport
|
v
plugin subprocess
Each layer has one responsibility.
pkg/hashiplugin/sdk is the new author-facing layer.
It provides:
sdk.MustModule(...)sdk.Function(...)sdk.Object(...)sdk.Method(...)sdk.MethodSummary(...)sdk.MethodDoc(...)sdk.MethodTags(...)sdk.Callsdk.Serve(...)This package does not replace contract or shared. It implements contract.JSModule on behalf of plugin authors and centralizes manifest building, invoke dispatch, and value conversion.
Method metadata is now intentionally split by role:
sdk.ExportDoc(...) documents top-level function exports,sdk.ObjectDoc(...) documents object exports,sdk.MethodSummary(...) gives goja-repl tui and other compact UIs a one-line description,sdk.MethodDoc(...) provides the fuller method body,sdk.MethodTags(...) attaches lightweight classification labels for search and display.The runtime is not globally plugin-aware by default. An entrypoint opts in by constructing a runtime module spec and attaching it to the engine builder.
Current wired entrypoints:
cmd/goja-replcmd/bun-demopkg/repl/evaluators/javascriptThat means plugin support is explicit at composition time.
engine.RuntimeModuleRegistrar is the central extension seam. A runtime module spec receives:
require.Registry,This is the design point that makes plugins feasible without adding plugin-specific lifecycle hacks to the engine.
pkg/hashiplugin/host is the policy layer. It decides:
This is intentionally separate from the transport contract so that future policy changes do not require redesigning the protobuf schema.
The shared contract and shared packages define:
go-plugin handshake constants,GRPCPlugin adapter helpers.They are narrow on purpose. The contract should not accumulate host-only policy or engine-specific concepts.
That is also why the SDK belongs beside them rather than inside host: authoring ergonomics and host policy are different concerns.
This is the end-to-end flow from CLI flag to require("plugin:echo").
cmd/goja-repl tui
|
v
engine.NewRuntimeFactoryBuilder()
.WithModules(..., host.NewRegistrar(...))
|
v
Factory.Build()
|
v
Factory.NewRuntime(engine.WithStartupContext(ctx), engine.WithLifetimeContext(ctx))
|
v
fresh require.Registry is created
|
v
runtime module specs register modules
|
v
host registrar discovers and starts plugin clients
|
v
validated plugin manifests are reified as native modules
|
v
registry is enabled on the runtime
|
v
JavaScript code can call require("plugin:echo")
The order matters. The runtime module registration phase must happen before reg.Enable(vm) because that is when native modules are registered into the runtime's require system.
This section maps the main code objects to their jobs.
engine.RuntimeModuleRegistrarFile: pkg/engine/runtime_modules.go
Purpose:
Why it matters:
engine.Runtime.AddCloserFile: pkg/engine/runtime.go
Purpose:
Why it matters:
host.ConfigFile: pkg/hashiplugin/host/config.go
Purpose:
Current defaults:
goja-plugin-*plugin:10s5sRelevant policy fields:
DirectoriesAllowModulesPatternNamespaceThis keeps user-facing configuration small while still centralizing policy.
host.DiscoverFile: pkg/hashiplugin/host/discover.go
Purpose:
Why it matters:
host.ValidateManifestFile: pkg/hashiplugin/host/validate.go
Purpose:
Why it matters:
host.LoadModuleFile: pkg/hashiplugin/host/client.go
Purpose:
go-plugin,Implementation notes:
host.RegisterModuleFile: pkg/hashiplugin/host/reify.go
Purpose:
This is where the remote contract becomes an in-process require() module. For each manifest export, the host registers either:
Invoke(...), orInvoke(...).This is the most important conceptual bridge in the system: remote plugin exports are reified as local CommonJS exports.
host.NewRegistrarFile: pkg/hashiplugin/host/registrar.go
Purpose:
In practice, this is the one object entrypoints need.
The protobuf contract lives in pkg/hashiplugin/contract/jsmodule.proto.
At a high level, the service exposes:
GetManifest(...)Invoke(...)The manifest describes:
An export spec describes:
Invocation carries:
structpb.Value entries.Response carries:
structpb.Value result.This contract is intentionally JSON-shaped. That keeps cross-process data handling simple and predictable at the cost of not trying to expose richer host-specific value types in v1.
When JavaScript calls a plugin-backed export, the conversion flow is:
goja.Value arguments
|
v
arg.Export()
|
v
structpb.NewValue(...)
|
v
gRPC Invoke call
|
v
structpb.Value response
|
v
AsInterface()
|
v
vm.ToValue(...)
This path is simple, but it sets the practical constraints for plugin authors:
goja-repl tuicmd/goja-repl now exposes the TUI through the tui subcommand. It resolves plugin directories directly from the shared root flags: explicit --plugin-dir flags win, otherwise the command scans ~/.go-go-goja/plugins/....
That means both the lower-level evaluator integration and the top-level TUI flag wiring are now present in:
pkg/repl/evaluators/javascript/evaluator.gopkg/repl/adapters/bobatea/javascript.goThe TUI entrypoint also uses the shared replapi runtime/session stack while keeping the Bobatea completion/help widgets.
It also exposes --allow-plugin-module, which is forwarded through the evaluator config into the host registrar.
The main end-to-end test is pkg/hashiplugin/host/registrar_test.go.
It covers:
plugin:echo,plugin:examples:greeter example,plugin:examples:kv example and verifying state survives across calls,plugin:examples:failing example and verifying handler errors surface back to the caller,The user-facing example plugin sources currently live under:
plugins/examples/greeterplugins/examples/clockplugins/examples/validatorplugins/examples/kvplugins/examples/system-infoplugins/examples/failingThe integration-test fixture plugins live under:
plugins/testplugin/echoplugins/testplugin/invalidThis split is intentional. plugins/examples/... is for copyable authoring examples and documentation, while plugins/testplugin/... stays small and deterministic for integration tests.
The current state is intentionally mixed:
plugins/examples/greeter now uses the richer SDK surface and is the primary authoring example,plugins/examples/clock, validator, kv, system-info, and failing expand the catalog so different SDK features are demonstrated in isolation,plugins/testplugin/echo also uses the SDK so integration tests exercise the real authoring path,plugins/testplugin/invalid remains handwritten so the suite still covers the raw contract path.This section explains where to make changes if the subsystem grows.
If you want plugin support in another runtime consumer:
host.NewRegistrar(...).Do not reimplement discovery logic in the entrypoint.
If you want checksums, allowlists, or richer trust policy:
host.Config,If you want richer exported shapes:
Do not skip the validation layer. Reification should assume the manifest is already trusted structurally.
| Problem | Cause | Solution |
|---|---|---|
| Plugin support feels hard to trace across files | The feature spans engine, transport, host policy, and CLI wiring | Read the files in the order listed in the Read this first section |
| A new entrypoint cannot see plugins | The entrypoint builds a runtime but never attaches the plugin runtime module spec | Add WithModules(host.NewRegistrar(...)) or set PluginDirectories on the evaluator config |
| A plugin starts but registration fails | Manifest validation rejected the module shape | Start in pkg/hashiplugin/host/validate.go and compare the plugin manifest to the current rules |
| Runtime closes but a plugin process remains alive | Cleanup registration was skipped or a new integration path bypassed owned runtime shutdown | Confirm the runtime path uses engine.Runtime and registers closers through AddCloser(...) |
| A transport change breaks host code | Transport and host policy concerns got mixed together | Keep contract and shared narrow, and push policy back into pkg/hashiplugin/host |
goja-repl help goja-plugin-user-guide — User-facing reference for loading and calling pluginsgoja-repl help plugin-tutorial-build-install — Step-by-step plugin build and install walkthroughgoja-repl help repl-usage — General REPL usage and command entrypointsgoja-repl help creating-modules — In-process native module authoring, which is the closest existing parallel concept