A Go web gateway whose features are plugins — separate processes that Core starts, supervises, hot-reloads and upgrades without dropping a request.
Plugins serve their own HTTP APIs, ship their own micro-frontends, and can intercept any request in the gateway's lifecycle, in the style of an IIS filter. Enabling one makes its menu appear in the console; disabling one makes it disappear, with no page reload.
Browser ──HTTP──▶ Core (:80)
├─ filter pipeline pre_route → authenticate → authorize →
│ pre_handler → [backend] → post_handler → log
├─ /api/plugins/* a plugin's own HTTP API
├─ /plugins/* a plugin's micro-frontend
└─ PluginHost ──exec──▶ plugin subprocess ×N
│ HashiCorp go-plugin over a unix socket
▼
HostServices: documents · durable queue · cache · locks
config · files · outbound HTTP · events · logs & metrics
Core listens on one port. Plugins open none.
Core starts each plugin with exec, which is what makes the rest possible:
- Hot load, unload and upgrade. Core owns the process, so it can start a new version, health-check it, swap traffic atomically and drain the old one. Measured with continuous traffic across a swap: zero failed requests.
- Crash isolation with recovery. A plugin panic does not touch Core. The supervisor restarts it with exponential backoff and quarantines one that keeps crashing.
- No network exposure. There is no port to reach a plugin on, and no registration protocol to authenticate — Core is the parent process.
A plugin declares which lifecycle phases and paths it cares about, and Core compiles that into a match table. Requests nobody subscribed to cost almost nothing:
| phase with no subscribers | 1.9 ns, zero allocations |
| subscribed, path does not match | 8.2 ns, zero allocations |
| an actual cross-process filter call | ~37,000 ns |
Filters default to fail-open, because most of them observe rather than guard
and a broken observer should not take the site down. Anything enforcing a
security decision opts in to fail_closed.
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /notes", listNotes)
sdk.Serve(sdk.Config{
Handler: mux,
Filters: map[sdk.Phase]sdk.FilterFunc{
sdk.PhasePreRoute: rateLimit,
},
Jobs: map[string]sdk.JobFunc{
"nightly-summary": summarise,
},
})
}sdk.Serve takes a standard http.Handler, so any router or middleware works
unchanged. Everything a plugin can reach — the document store, the queue,
caching, locks, files, outbound HTTP — goes through Core, and every call
carries the request's trace id automatically, so a slow query is attributable
to the request that caused it.
Full guide: docs/plugin-development.md.
Three worked examples, each showing a different shape:
notes |
a destination — own API, own table, own menu, own scheduled job |
ratelimit |
a gate — no routes, no tables, no permissions at all, and it governs everyone else's traffic |
audit |
a recorder — writes down what happened after the response has gone |
Each has a README explaining why it is built the way it is. The audit one is worth reading for a different reason: it was written by an agent allowed to read only the plugin guide, as a way of finding what the guide failed to say.
git clone git@github.com:taills/moduless.git
cd moduless
# Build the console once
cd core/frontend && npm install && npm run build && cd ../..
# Build the example plugins into the plugin directory
./scripts/build-examples.sh
# Run. Without DATABASE_URL the data, queue and file capabilities report
# Unavailable and everything else still works.
PLUGIN_DIR=./plugins go run ./coreOr with Docker:
docker compose up --build # console at http://localhost:8080, admin / admin123Plugins never connect to PostgreSQL. They declare collections in
manifest.yaml, Core provisions the tables, and access goes through a
document store with sorting, keyset pagination, aggregation, batch writes,
transactions and optimistic locking.
Keyset pagination rather than OFFSET is deliberate: OFFSET makes the database walk and discard every skipped row, so deep pages get slower, and rows shifting between requests silently duplicate or skip entries.
The durable queue is PostgreSQL-backed — at-least-once delivery with retries, backoff, dead-lettering, delayed messages and deduplication, without adding a broker to the deployment.
Plugins are reviewed by an operator before installation and run with Core's own privileges, like an ISAPI filter inside the IIS worker process. Safety comes from only installing plugins you trust.
Core does enforce, on its own side of the connection: the permission set a plugin declared, per-plugin namespacing of documents, cache, queue and files, transaction ownership, the outbound HTTP allow-list (including refusing addresses that resolve to private or link-local ranges), and a SHA-256 check that the binary is the one that was installed.
It does not confine the filesystem, CPU or system calls. Genuinely untrusted code belongs behind a container boundary.
go test ./... -race
# Tests needing a database or object storage skip without these
TEST_DATABASE_URL='postgres://...' TEST_S3_ENDPOINT='http://localhost:19000' go test ./...
# The console
cd core/frontend && npm test
# On Linux, which is what this ships on. Some behaviour differs from macOS in
# ways that matter here: writing to a running executable fails with ETXTBSY,
# and Pdeathsig exists at all.
docker run --rm -v "$(pwd)":/src -w /src -e CGO_ENABLED=0 \
golang:1.25-alpine go test ./... -count=1The end-to-end suite forks real plugin processes and drives them over real HTTP: a hot upgrade under continuous load, a deliberate crash, a request that spans an upgrade, three plugins interacting in one Core, and a database that goes away and comes back.
See tests/README.md for how the suite is kept honest —
including mutation-testing the security boundaries, and the rule this codebase
kept relearning: assert the reason, not just the outcome.
Apache 2.0 — see LICENSE. Dependency licence review in
THIRD_PARTY_NOTICES.md.