Skip to content

The Plugin Lifecycle

Understanding the plugin lifecycle makes it clear why code goes where it goes — and what breaks when it doesn’t.

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 server

publishPackageRuntimeState() 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.

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.

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.

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.

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.

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.

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.

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.

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.