Skip to content

Plugin Interface

SlingshotPlugin is the framework-level plugin authoring contract. Use it for auth, persistence, queues, search, and other infrastructure that needs async bootstrap, registrar registrations, or conditional middleware. It’s a parallel first-class authoring tier to definePackage — pick by the shape of what you’re building, not by perceived level. See Composing an App for the decision guide.

The type lives in @lastshotlabs/slingshot-core.

import type { Hono } from 'hono';
import type { PluginSetupContext } from '@lastshotlabs/slingshot-core';
interface SlingshotPluginShape {
name: string;
dependencies?: string[];
tenantExemptPaths?: string[];
csrfExemptPaths?: string[];
publicPaths?: string[];
setupMiddleware?(ctx: PluginSetupContext): void | Promise<void>;
setupRoutes?(ctx: PluginSetupContext): void | Promise<void>;
setupPost?(ctx: PluginSetupContext): void | Promise<void>;
setup?(ctx: PluginSetupContext): void | Promise<void>;
teardown?(): void | Promise<void>;
}
type StandaloneApp = Hono;
void (0 as unknown as StandaloneApp);
src/notes-plugin.ts
import {
type SlingshotPlugin,
defineEvent,
getActor,
getContext,
publishPluginState,
} from '@lastshotlabs/slingshot-core';
type Note = {
id: string;
text: string;
};
type NotesAdapter = {
list(userId: string | null): Promise<Note[]>;
deleteAll(userId: string): Promise<void>;
};
export interface NotesPluginConfig {
maxNotesPerUser: number;
}
declare module '@lastshotlabs/slingshot-core' {
interface SlingshotEventMap {
'note.created': { userId: string; noteId: string };
'note.deleted': { userId: string; noteId: string };
}
}
export function createNotesPlugin(config: NotesPluginConfig): SlingshotPlugin {
return {
name: 'notes',
dependencies: ['slingshot-auth'],
setupRoutes({ app }) {
app.get('/notes', async c => {
const ctx = getContext(app);
const adapter = ctx.pluginState.get('notes.adapter') as NotesAdapter | undefined;
if (!adapter) {
return c.json({ error: 'Notes adapter not initialized' }, 500);
}
const actor = getActor(c);
const notes = await adapter.list(actor.kind !== 'anonymous' ? actor.id : null);
return c.json(notes, 200);
});
},
setupPost({ app, bus, events }) {
const ctx = getContext(app);
const adapter = ctx.pluginState.get('notes.adapter') as NotesAdapter | undefined;
publishPluginState(ctx.pluginState, 'notes.config', config);
bus.on('auth:user.deleted', async ({ userId }) => {
if (adapter) {
await adapter.deleteAll(userId);
}
});
events.register(
defineEvent('note.created', {
ownerPlugin: 'notes',
exposure: ['client-safe'],
resolveScope(payload) {
return { userId: payload.userId, actorId: payload.userId };
},
}),
);
events.register(
defineEvent('note.deleted', {
ownerPlugin: 'notes',
exposure: ['client-safe'],
resolveScope(payload) {
return { userId: payload.userId, actorId: payload.userId };
},
}),
);
},
teardown() {
// Close plugin-owned resources here when needed.
},
};
}

The framework runs three phases in order across all plugins - finishing every plugin’s setupMiddleware before starting any plugin’s setupRoutes. Order within a phase follows your dependencies declaration.

The framework validates dependency presence and cross-phase ordering before boot. A plugin that only defines setupRoutes cannot safely depend on a plugin whose first active phase is setupPost, and standalone-only plugins that define only setup() are skipped by framework-managed boot entirely.

Runs before any route is matched. Mount middleware that must intercept every request: JWT verification, CSRF validation, tenant identification, request guards.

The auth plugin mounts userAuth here so it is available across all subsequent route files, regardless of which plugin registered those routes.

Runs after setupMiddleware finishes on all plugins, and after tenant middleware. Mount your Hono routers, register OpenAPI routes, and do any setup that needs tenant context.

setupPost - wire up the fully assembled app

Section titled “setupPost - wire up the fully assembled app”

Runs after all routes across all plugins are registered. The app is fully assembled at this point. Use it for:

  • Subscribing to event bus events
  • Reading state other plugins published through pluginState
  • Cross-plugin capability registration such as permissions, channels, or search sync

If the root app or server config includes ws, Slingshot also seeds getContext(app).wsEndpoints before setupPost runs. That lets a plugin attach incoming, onRoomSubscribe, or other endpoint hooks during bootstrap, and createServer() later starts from that same endpoint map. Declare the endpoint path in the root ws.endpoints config first, then mutate that draft in setupPost rather than inventing ad hoc endpoint keys inside the plugin. The root ws config uses the same WsConfig shape whether you call createApp() or createServer(), so these bootstrap mutations stay type-checked even when the app never starts a live transport in the current process. createApp() carries that root ws draft only far enough for plugin lifecycle wiring and context assembly. createServer() reuses the same typed draft later when it owns transport startup.

Called on SIGTERM and SIGINT. Close database connections, cancel background timers, and flush queues.

export function createNotesPlugin(config: NotesPluginConfig): SlingshotPlugin {
return {
name: 'notes',
dependencies: ['slingshot-auth', 'slingshot-permissions'],
};
}

Slingshot validates and topologically sorts plugins before running the lifecycle. If slingshot-auth has not booted yet when notes tries to read from ctx.pluginState, bootstrap fails instead of silently producing undefined.

Declare everything you depend on. Leave off what you do not.

Use pluginState to make your plugin’s runtime available to sibling plugins:

type NotesAdapter = {
list(userId: string | null): Promise<unknown[]>;
};
declare function createNotesAdapter(): NotesAdapter;
declare const config: { maxNotesPerUser: number };
setupRoutes({ app }) {
const ctx = getContext(app);
publishPluginState(ctx.pluginState, 'notes.adapter', createNotesAdapter());
publishPluginState(ctx.pluginState, 'notes.config', config);
}

Another plugin reads it:

type NotesAdapter = {
list(userId: string | null): Promise<unknown[]>;
};
setupPost({ app }) {
const ctx = getContext(app);
const notesAdapter = ctx.pluginState.get('notes.adapter') as NotesAdapter | undefined;
if (notesAdapter) {
void notesAdapter;
}
}

Capability resolvers must return a stable reference across every lifecycle phase. The framework calls provider.resolve() twice per package (once in setupMiddleware and once in setupPost) and republishes the slot each time; if you return a fresh object on each call, downstream consumers that captured the cap in setupMiddleware will observe !== against the same cap resolved in setupPost. The canonical pattern is to construct a long-lived Proxy once per package instance and have it defer to a closure-owned stateRef that gets populated as adapters become available — see packages/slingshot-interactions/src/plugin.ts and packages/slingshot-orchestration/src/plugin.ts for representative implementations.

The framework never calls setup(). It exists for packages that also support a plain Hono integration outside createApp():

import { Hono } from 'hono';
import {
type PluginSetupContext,
type SlingshotPlugin,
createEventDefinitionRegistry,
createEventPublisher,
createInProcessAdapter,
} from '@lastshotlabs/slingshot-core';
declare const notesPlugin: SlingshotPlugin;
const app = new Hono() as PluginSetupContext['app'];
const config = {} as PluginSetupContext['config'];
const bus = createInProcessAdapter();
const events = createEventPublisher({
definitions: createEventDefinitionRegistry(),
bus,
});
await notesPlugin.setup?.({ app, config, bus, events });

Framework usage is different: the framework calls setupMiddleware, setupRoutes, and setupPost in order, and it never calls setup().

When the framework does call lifecycle methods, it invokes them on the plugin instance itself. That means plugin authors can safely read closure-owned config or instance state from this if they deliberately choose method syntax, although most Slingshot plugins still prefer closure-owned factories over instance mutation.