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:
- A PocketBase JS hook → put a
.pb.jsfile inpb-hooks/. - A migration → put a file in
pb-migrations/. - Client-only code → don’t ship server code at all.
Reach for Go only when you need:
- A long-lived network server (IMAP, SMTP, WebRTC signalling).
- Streaming or binary HTTP endpoints that JS hooks can’t serve cleanly.
- Logic that must run outside any PocketBase record event (cron-style tickers, worker pools).
- Native integrations the PocketBase JS runtime doesn’t expose.
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:
-
Regenerates
tinycld/server/package_extensions.go, a small Go file whoseregisterPackageExtensions(app)calls each package’sRegister(app). The app shell’smain.goinvokes 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) } -
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 workspacenode_modules/@tinycld/*symlinks):go 1.25.0 use ( . ../../node_modules/@tinycld/core/server ../../node_modules/@tinycld/example/server )The
go.workfile is written only when at least one present feature ships a server; it’s removed when none do. Because module locations come fromgo.work, no per-packagereplacedirectives are appended togo.mod- the file stays hand-authored. -
Writes
tinycld/server/bundled-packages.json, the seed manifest core’s Go server uses to hydrate itspkg_registrycollection 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.
| Registry | Call | What it does |
|---|---|---|
| OAuth scopes | oauth.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. |
| Search | search.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 quota | quota.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.) |
| Offboarding | offboard.RegisterReassignable(offboard.ReassignableRef{Collection, Field}) | Rows to hand to another user when an account is deleted or disabled. |
| Audit log | audit.RegisterCollection(app, "example_items", cfg) | Records create/update/delete on the collection in the audit log. |
| Version hooks | versionhooks.Register("example", versionhooks.Hook{OnSnapshot, OnRestore}) | Lets Drive’s version history snapshot and restore a document type your package owns. |
| Inbound webhooks | webhookin.Register("github", webhookin.Source{…}) | Serves POST /api/webhooks/{name} with signature verification and a replay ledger; see Automation. |
| Automation | automation.RegisterAction, RegisterTriggerFilter, RegisterOwnerResolver, RegisterRelationAuthorizer | The 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.