Go server extensions

A package that needs to run Go code on the server - IMAP/SMTP, custom HTTP endpoints, long-lived workers, anything that doesn’t fit in PocketBase’s JS hooks - ships a server/ subdirectory with its own Go module. The generator wires it into a generated go.work and into the generated tinycld/server/package_extensions.go entry point. For the contract the package itself implements (the Register(app) function, the module layout, testing), see Server. This page covers what the app shell does around that.

When to use Go

Most packages don’t need a Go server. If you can express your logic as:

Reach for Go only when you need:

Core’s Go module

@tinycld/core is a nested member inside the tinycld repo (at tinycld/core/), and its Go side is the module tinycld.org/core at tinycld/core/server/, exporting coreserver (the registration orchestrator) plus some forty subsystems a package can lean on: automation, oauth, search and fts, quota, webhookin, notify, push, mailer and mailproto, audit, logging, sharelink, driveshare, guestauth, ratelimit, safehttp, embedpolicy, versionhooks, offboard, caldav, carddav, webdav, markdown, yjsdoc, textextract, thumbnails, previewqueue, render, realtime, and emoji among them. The app server (tinycld/server/main.go) is the module tinycld.org/app and consumes core via a hand-written replace directive in tinycld/server/go.mod:

require tinycld.org/core v0.0.0

replace tinycld.org/core => ../core/server

That path is relative to tinycld/server/ and points at the nested core’s server/ subdirectory at ~/code/tinycld/tinycld/core/server/.

Declaring a package module

Two fields in a feature’s manifest:

server: { package: 'server', module: 'tinycld.org/packages/example' },

package is the subdirectory name, by convention 'server'. module is the Go module path declared in that subdirectory’s go.mod - use the tinycld.org/packages/<slug> namespace to keep module paths out of collisions. A feature’s go.mod requires tinycld.org/core v0.0.0; it does not need its own replace directive, because the generated go.work resolves every module’s location.

What the generator writes

On each pnpm run packages:generate (and on the workspace-root pnpm install), for every present feature with a server field, the generator:

  1. Regenerates tinycld/server/package_extensions.go, a small Go file whose registerPackageExtensions(app) calls each package’s Register(app). The app shell’s main.go invokes it (RegisterExtras: registerPackageExtensions) so every present package gets a chance to wire in hooks, endpoints, and workers before the server starts.

    // Code generated by tinycld/scripts/generate.ts. DO NOT EDIT.
    package main
    
    import (
        "github.com/pocketbase/pocketbase"
        example "tinycld.org/packages/example"
    )
    
    func registerPackageExtensions(app *pocketbase.PocketBase) {
        example.Register(app)
    }
  2. Writes tinycld/server/go.work, a Go workspace file listing the app, core, and each feature server module by its on-disk path (resolved through the workspace node_modules/@tinycld/* symlinks):

    go 1.25.0
    
    use (
        .
        ../../node_modules/@tinycld/core/server
        ../../node_modules/@tinycld/example/server
    )

    The go.work file is written only when at least one present feature ships a server; it’s removed when none do. Because module locations come from go.work, no per-package replace directives are appended to go.mod - the file stays hand-authored.

  3. Writes tinycld/server/bundled-packages.json, the seed manifest core’s Go server uses to hydrate its pkg_registry collection at boot.

Registries

Core knows nothing about any package. Everything core needs to learn about yours — which OAuth scopes exist, what is searchable, what counts toward storage, what to reassign when a user leaves — you declare from your own Register(app), the same place you bind every other hook. Each registry is idempotent per key, so a dev reload re-registering is not an error.

RegistryCallWhat it does
OAuth scopesoauth.RegisterPackage(oauth.Package{…})Declares the package’s scopes (example:read, example:write), the collections and routes each governs, and the consent-screen labels. This is what the tinycld CLI’s device-flow login requests.
Searchsearch.RegisterSources(source)Contributes a source to the federated GET /api/search behind the palette and tinycld search. Pair it with fts for the index.
Storage quotaquota.RegisterSources(sources...)Names the collections holding file bytes so core can enforce the per-user and deployment ceilings as record hooks. (Or declare them in the manifest’s quota block.)
Offboardingoffboard.RegisterReassignable(offboard.ReassignableRef{Collection, Field})Rows to hand to another user when an account is deleted or disabled.
Audit logaudit.RegisterCollection(app, "example_items", cfg)Records create/update/delete on the collection in the audit log.
Version hooksversionhooks.Register("example", versionhooks.Hook{OnSnapshot, OnRestore})Lets Drive’s version history snapshot and restore a document type your package owns.
Inbound webhookswebhookin.Register("github", webhookin.Source{…})Serves POST /api/webhooks/{name} with signature verification and a replay ledger; see Automation.
Automationautomation.RegisterAction, RegisterTriggerFilter, RegisterOwnerResolver, RegisterRelationAuthorizerThe Go halves of the triggers and actions your manifest’s automation.definitions declares.

A representative Register:

func Register(app *pocketbase.PocketBase) {
    registerShared(app)
    if tc, ok := coreserver.GetTenantContext(app); ok {
        // embedded under a hosting supervisor: sockets are injected, nothing binds
        registerInjectedListeners(app, tc.Mail)
        return
    }
    registerOwnListeners(app)
}

func registerShared(app *pocketbase.PocketBase) {
    oauth.RegisterPackage(oauthPackage())
    search.RegisterSources(searchSource())
    audit.RegisterCollection(app, "example_items", &audit.CollectionConfig{})
    offboard.RegisterReassignable(offboard.ReassignableRef{Collection: "example_items", Field: "owner"})
    registerAutomation()
    registerRoutes(app)
}

Register composes; registerShared is the single source of truth for what runs everywhere. coreserver.GetTenantContext is present only when the process is embedded in a hosting supervisor that owns the public ports — a self-hosted deployment has none, and a package must never fork registerShared on it.

Event sources are the one cross-package contribution that is not a Go registry: they are declared in the manifest and implemented as a TypeScript hook. See Event sources.

Testing the Go side

A feature’s Go module is self-contained - it pins the same PocketBase version core uses, so test code hits the same API surface it will in production:

cd ~/code/tinycld/example/server
go test ./...

Within an assembled workspace, the generated go.work ties the feature module, core, and the app together, so a build from tinycld/server/ reflects exactly what the app ships. Core also has its own Go tests under tinycld/core/server/**/_test.go — run those from tinycld/core/server/ with go test ./....

For the package-side concerns (the Register function signature, the directory layout, what to import), see Server.