The Plugin Lifecycle
Understanding the plugin lifecycle makes it clear why code goes where it goes — and what breaks when it doesn’t.
What happens when you call createServer()
Section titled “What happens when you call createServer()”createApp({ plugins, packages }) accepts two tiers. Plain plugin-tier entries flow through
the lifecycle directly; package-tier entries are first compiled by compilePackages() into
SlingshotPackageDefinition-wrapping plugins that interleave the entity-plugin hooks with
the package’s own hooks plus two publishPackageRuntimeState() passes. From step 3 onward,
plain plugins and compiled packages share the same dispatch table.
createServer(config) │ ├── 1. Validate config ├── 2. compilePackages([...]) — wrap each SlingshotPackageDefinition in a plugin that │ interleaves entityPlugin.* hooks + publishPackageRuntimeState() with pkg.* hooks ├── 3. Create SlingshotContext (per-app state, including any WS endpoint drafts) ├── 4. Initialize infrastructure (DB connections, event bus, framework adapters) ├── 5. Topologically sort the combined plugin list by dependency declarations │ ├── 6. Run setupMiddleware on every entry, in order │ ├── Plain plugin: pkg.setupMiddleware │ └── Compiled package: entityPlugin.setupMiddleware │ → publishPackageRuntimeState() (first pass) │ → pkg.setupMiddleware │ ├── 7. Mount tenant middleware │ ├── 8. Run setupRoutes on every entry, in order │ ├── Plain plugin: pkg.setupRoutes │ └── Compiled package: entityPlugin.setupRoutes │ → pkg.setupRoutes │ → mountRoutes (entity router for the package) │ ├── 9. Mount error handlers │ ├── 10. Run setupPost on every entry, in order │ ├── Plain plugin: pkg.setupPost │ └── Compiled package: entityPlugin.setupPost │ → publishPackageRuntimeState() (second pass) │ → pkg.setupPost │ └── 11. Start the HTTP serverpublishPackageRuntimeState() is called twice per package. Capability resolvers
(provideCapability(Cap, () => view)) return the same long-lived value across both passes
and across every later read of ctx.capabilities.require(Cap), so === identity is stable.
The three phases
Section titled “The three phases”setupMiddleware — run early, before route matching
Section titled “setupMiddleware — run early, before route matching”Runs after framework core middleware, before tenant resolution. Use it for:
- Auth middleware that sets user context on every request
- CSRF protection
- Request guards that intercept before any route matches
- Security fingerprinting and rate limit state
slingshot-auth uses this phase — it has to. Downstream packages call getActor(c) from their route handlers, so auth must run first.
setupRoutes — mount routes after tenant context is available
Section titled “setupRoutes — mount routes after tenant context is available”Runs after tenant middleware — routes registered here have tenant context. Use it for:
- Mounting Hono routers
- Entity-driven plugin routes via
createEntityPlugin() - Package-owned route families
- Publishing plugin-owned runtime state when it becomes canonical during route assembly
Order matters here. If plugin A’s routes depend on middleware mounted by plugin B, A must declare B as a dependency so B boots first.
setupRoutes is also where the entity system now publishes resolved entity adapters into plugin
state. That exception exists so dependency-ordered route composition can look up sibling adapters
without waiting for setupPost.
setupPost — after everything is wired
Section titled “setupPost — after everything is wired”Runs after all routes and error handlers are registered. The app is fully assembled — not yet serving requests. Use it for:
- Event bus subscriptions
- Registrar writes and other post-assembly framework discovery
- Cross-plugin discovery (finding what other plugins have registered)
- Initial state setup that depends on the full plugin graph being present
This phase is also the last bootstrap point before the WS server starts. When the root config
includes ws, createApp() copies the declared endpoint map into ctx.wsEndpoints before
setupPost, so plugins can attach incoming and onRoomSubscribe handlers here. createServer()
then boots from that same live endpoint map instead of rebuilding it from scratch later.
The root ws field is typed as the normal WsConfig shape on both createApp() and
createServer(), so plugin authors are mutating the same typed draft regardless of whether the
app is being assembled for tests, package bootstrap, or a live server process.
App assembly owns seeding the endpoint draft into context; server bootstrap owns turning that draft
into a live transport listener.
Do not mount routes in setupPost. They’ll miss tenant middleware and other assumptions the app makes about route registration order.
Dependency ordering
Section titled “Dependency ordering”Plugins declare their dependencies by name:
export function createCommunityPackage() { return { name: 'slingshot-community', dependencies: ['slingshot-auth'], // ... };}Slingshot validates that all declared dependencies are present and topologically sorts execution order. Missing slingshot-auth? It throws at startup — not at the first request.
Cross-package data exchange
Section titled “Cross-package data exchange”Packages exchange data through typed package contracts. A provider declares a contract with
definePackageContract('my-package'), names typed handles via MyPackage.capability<T>('name'),
and publishes a resolver via definePackage({ capabilities: { provides: [provideCapability(Cap, () => view)] } }).
Consumers resolve the handle through ctx.capabilities.require(Cap). This is the canonical
cross-tier discovery surface — see the
Package Contracts guide for the full story.
pluginState is the legacy escape hatch. It remains supported for plugin-tier modules that do
not have a package contract (and for framework-owned slots like the entity adapter publishing
hook used internally during setupRoutes), but new package-tier code should prefer capability
handles:
import { getContext, publishPluginState } from '@lastshotlabs/slingshot-core';
const MY_PLUGIN_STATE_KEY = 'my-plugin' as const;declare const app: object;
publishPluginState(getContext(app).pluginState, MY_PLUGIN_STATE_KEY, { someCapability: true });const pluginAState = getContext(app).pluginState.get(MY_PLUGIN_STATE_KEY);slingshot-auth publishes user resolution this way for slingshot-community to consume — both
sides are plugin-tier through this slot — without either package importing the other’s internals.
Teardown
Section titled “Teardown”Plugins can implement teardown() for graceful shutdown:
{ name: 'my-plugin', teardown: async () => { await closeConnections(); },}ctx.destroy() calls teardown on all plugins and closes DB connections. Use it in tests and in process signal handlers.
Tracing visibility
Section titled “Tracing visibility”When distributed tracing is enabled, each plugin lifecycle call produces an individual OTel span named slingshot.plugin.<name>.<phase> (e.g. slingshot.plugin.auth.setupMiddleware). The spans include slingshot.plugin.name, slingshot.plugin.phase, and slingshot.plugin.dependency_count attributes, making slow plugins visible in your trace backend. Plugin authors can also create child spans within request handlers using createChildSpan.
Validation notes
Section titled “Validation notes”Dependency ordering is enforced before any plugin code runs. Slingshot rejects missing dependencies, circular graphs, and impossible cross-phase edges instead of trying to continue with a partially valid plugin graph.
Standalone-only plugins are also treated explicitly: a package that defines only setup() can still support plain Hono usage, but createApp() and createServer() skip it unless it also exposes one of the framework lifecycle phases.
That same lifecycle ordering also applies when Slingshot assembles an app for non-listening hosts
such as package inspection (inspectPackage), tests, or Lambda cold start. The host boundary
changes, but the plugin phases and context finalization rules stay the same.
Helpers introduced with the package-first migration
Section titled “Helpers introduced with the package-first migration”Three companion helpers ship alongside definePackage() and the seed lifecycle phase:
isPackageRegistered(app, packageName) — boolean check used inside setupMiddleware
or setupPost to detect optional peer packages. The cross-package idiom for “if
slingshot-interactions is wired, attach the peer-publish hook; otherwise no-op”:
import { isPackageRegistered } from '@lastshotlabs/slingshot-core';
declare const app: object;
if (isPackageRegistered(app, 'slingshot-interactions')) { // wire the optional integration}createLazyMiddleware(resolveAdapter, build) — wraps a middleware factory so the
adapter is resolved at first request rather than at registration time. Lets a package
register middleware in setupMiddleware before a peer package’s setupPost has
published the adapter through provideCapability(...). Used by slingshot-chat and
slingshot-community to attach permissions guards without ordering hazards.
runPackageLifecycle(packageDefinition, ctx) — testing-only helper exported from
@lastshotlabs/slingshot-entity/testing. Drives a package through its lifecycle phases
inside a unit test without spinning up a full createApp() graph. Use it when you need
to assert that a single package wires its capabilities and entity adapters correctly in
isolation.
The seed lifecycle phase
Section titled “The seed lifecycle phase”CreateAppConfig.seed adds a boot-time, idempotent seed phase that runs after
setupPost and before HTTP listen. Packages implement it by exporting a seed hook
from definePackage(...); slingshot-auth, slingshot-permissions, and
slingshot-organizations use it today to ensure super-admin grants, default roles, and
default organization records exist on every boot. This is distinct from the
slingshot seed CLI command, which generates faker-based entity fixtures for local
development.
See also
Section titled “See also”- Context and Registrar — what’s inside
SlingshotContext - Plugin Interface — the full
SlingshotPlugincontract - Observability & Distributed Tracing — bootstrap, request, and plugin lifecycle spans