loon

module
v0.0.0-...-5c0772c Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 28, 2026 License: MIT

README

loon

loon

loon is a site framework extracted from a production indexer: a plugin runtime and mediator kernel for building community content sites — Usenet indexers, torrent trackers, or anything with a catalog, an economy, and members — the way Gazelle sites build on Gazelle, but as a Go module you require.

What's in the box

  • core — the mediator (core.New(Deps), fails loud) and plugin runtime: Caddy-style registration, deterministic topo-sort boot, per-schema plugin migrations, process-kind filtering (web / worker / all), an extension registry for cross-plugin services, and interface seams for everything a plugin consumes: Users, Auth (Optional / Authenticate / RequireUser / RequireRole), RBAC, Storage, Scheduler, Router, Config, Notifications, Points (typed-ledger facade), HTTPClient, Errors.
  • catalog — the domain-swap seam: MetadataSource (Domain / TitleIndex / Fetch / Normalize) with optional TitleFinder, CrossIDResolver, and CompletionProvider capabilities, EntityRef/CatalogEntry neutral types, and a priority-ordered Registry. Register an anime source, a movie source, or a golf source — the host machinery doesn't change.
  • httpclient — the SSRF-safe outbound HTTP factory (pooled clients, user-URL SafeFetch with DNS-rebinding protection, host-allowlisted variants).
  • schedule — the job runtime: JobInfo, the registry, the cron-like ServiceLoop, off-peak gating, interval overrides, and CPU accounting behind the host's /admin/jobs.
  • nntp — an NNTP client + connection pool (greeting validation, overview fetch, STAT), used by the usenet indexer plugin.
  • img — small image helpers (resize/encode) with no cgo.

loon has zero dependencies on any application package: the host adapts its own storage, sessions, and job registry onto the interfaces at its composition root.

The extension directory

Plugins publish services for each other through one registry, and consume them by name:

c.Register("wiki.render", renderer)          // publish
svc, ok := c.Lookup("wiki.render")           // consume, then assert

That works and stays supported. But a name and a Go type answer what do I assert to and not what is this for, and — the one a type genuinely cannot answer — am I meant to call this, or supply it? func(context.Context, int64) error reads identically either way.

So a seam worth explaining registers with a definition:

c.RegisterDef(core.ExtensionDef{
    Name:    "rewards.trigger",
    Summary: "fire a surface's rewards for one member",
    Kind:    core.ExtService,
    Stable:  true,
}, engine)

Kind is the direction:

kind meaning
service the registrant offers behaviour; you call it
callback you supply it and its owner calls you — a host's counter for a per_unit reward is this
data a value rather than behaviour — a catalogue, a config set

Everything lands in the same registry with the same Lookup and the same duplicate rule; the definition only means the directory can describe it. /admin/plugins renders the lot — every core service with whether this host wired it, and every published extension with its kind, summary and the concrete type to assert to. An undescribed extension still appears, marked undescribed, because a seam you can see and not explain still beats one you cannot see.

A def with an empty summary is refused. It would take the space the answer goes in and give nothing back, and Register(name, svc) already says "no comment" more honestly.

Stable: false marks a seam still moving. A consumer may depend on it anyway — this only says they should expect to be broken, which is kinder than finding out.

Events

The other direction. An extension is something you call; an event is something that happens to you.

// The forum, in Provision -- declare what you emit so it is findable:
c.DeclareEvent(core.EventDef{
    Name: "forum.post.created", Summary: "a member posted in a thread",
    Emitter: "forum", Countable: true, Stable: true,
})

// ...and announce it, wherever a post is created:
c.Emit(ctx, core.Event{Name: "forum.post.created", UserID: u.ID, Subject: postID})

// Achievements, in ITS Provision -- listen:
c.On("forum.post.created", "achievements", func(ctx context.Context, e core.Event) {
    progress.Add(ctx, e.UserID, e.Count)
})

The forum does not know achievements exists. Achievements does not know the forum exists — only that something declared forum.post.created. Neither imports the other, and a third listener changes nothing on either side. A direct call between them would be a dependency, an ordering problem, and a reason for one plugin's bug to break the other.

Delivery is synchronous, in subscription order, and a handler must be quick — hand off to your own goroutine if it is not. The alternative is a queue, and a queue that loses its contents on restart is worse than a handler you can see blocking: it turns "the achievement did not fire" into an unfalsifiable claim.

A panicking subscriber is contained. The post already happened; one listener's bug must not unwind the action that announced it, nor stop the listeners after it.

Declaring is optional, and buys discoverability rather than permission. An undeclared event still delivers — failing a member's action over a missing doc comment would be absurd — but it does not appear in the directory, so the only way to learn it exists is to read the emitter's source.

Kind says who acted, and it is required because either default would be wrong half the time in a way nothing reports:

kind meaning
member a member did it; UserID is the actor and counting it against them is meaningful
system the site did it, or it happened to the site. UserID may name somebody involved, but they did not act

The case that forced the field: auth.failed_login_spike carries the username being guessed at — a victim, not an actor. Before Kind existed, the only thing stopping a subscriber counting failed logins against that member was a comment asking the emitter to leave UserID at zero. A system event may not be Countable, and core refuses the combination, because there is no member to total it against.

Emitting a member event with UserID zero is logged. That is an emitter that forgot, and the symptom is otherwise pure silence: every per-member subscriber skips it, the achievement never moves, and nothing says why.

Countable marks an event worth totalling per member. That is what an achievement threshold can be scored on; "member deleted their account" is an event nobody should build one from.

/admin/plugins lists every declared event with its emitter and its listeners, and separately lists subscriptions with no emitter — a typo, or a plugin this host does not have. Those are worth surfacing because a listener for an event that never fires is completely silent, and silence is exactly what it looks like when everything is fine.

Building and contributing

make help      # the targets
make check     # everything CI runs: fmt, vet, sqllint, test

The toolchain runs in a container (scripts/go.sh says why); make check GO=go uses the one on your machine instead, which is what CI does. There is no database and no integration target: loon defines the storage seam and the host fills it, so every test here is a unit test.

CONTRIBUTING.md covers what a change to a framework costs — CI builds loon-plugins against every commit, because loon can be green on its own while having broken every consumer. SECURITY.md covers reporting and what the httpclient guards actually promise.

Status

Production-proven: loon runs behind a full content site (~19 plugins) and powers the public loon-demo-site skeleton, which doubles as living documentation. Consumers pin it via a sibling-checkout replace github.com/the-loon-clan/loon => ../loon (or ../../loon). Tagged releases will follow.

Directories

Path Synopsis
Package bencode decodes just enough bencode to identify a .torrent.
Package bencode decodes just enough bencode to identify a .torrent.
Package blob is the storage seam for user-visible uploaded files (avatars, wiki images, community banners, admin-managed covers).
Package blob is the storage seam for user-visible uploaded files (avatars, wiki images, community banners, admin-managed covers).
Package catalog is the domain-swap seam from FRAMEWORK-ARCHITECTURE.md §4: the MetadataSource interface that collapses AniDB / MangaDex / MusicBrainz / TMDB from bespoke sibling services into implementations of one contract, so a site built on this framework can index movies, games, or golf rounds by registering a different source — no host changes.
Package catalog is the domain-swap seam from FRAMEWORK-ARCHITECTURE.md §4: the MetadataSource interface that collapses AniDB / MangaDex / MusicBrainz / TMDB from bespoke sibling services into implementations of one contract, so a site built on this framework can index movies, games, or golf rounds by registering a different source — no host changes.
Package core is the Phase-0 scaffolding for the indexer-site plugin architecture described in PLUGIN-ARCHITECTURE.md at the repository root.
Package core is the Phase-0 scaffolding for the indexer-site plugin architecture described in PLUGIN-ARCHITECTURE.md at the repository root.
Package httpclient provides preconfigured *http.Client factories so services don't each spin up bespoke clients with arbitrary timeouts and zero connection pooling.
Package httpclient provides preconfigured *http.Client factories so services don't each spin up bespoke clients with arbitrary timeouts and zero connection pooling.
RFC5322 date parsing.
RFC5322 date parsing.
Package schedule is loon's batteries-included background-job machinery: a process-global job registry with an admin-friendly snapshot surface (status, logs, CPU accounting, pause/stop/ trigger controls), admin-editable per-job config variables, and the ServiceLoop cron runner (boot delay, interval override, off-peak gating, panic recovery).
Package schedule is loon's batteries-included background-job machinery: a process-global job registry with an admin-friendly snapshot surface (status, logs, CPU accounting, pause/stop/ trigger controls), admin-editable per-job config variables, and the ServiceLoop cron runner (boot delay, interval override, off-peak gating, panic recovery).
scripts
lint-sql command
lint-sql walks the Go source under this module and flags any SQL query whose first argument (the SQL string) is built via string concatenation or fmt.Sprintf with non-literal substitution.
lint-sql walks the Go source under this module and flags any SQL query whose first argument (the SQL string) is built via string concatenation or fmt.Sprintf with non-literal substitution.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL