Skip to content

Building a Custom Plugin

Plugins extend Slingshot with your own routes, state, and event behavior. They are now the advanced/escape-hatch authoring surface. For standard packages, prefer definePackage(...), entity(...), domain(...), and typed capabilities first. Use a raw plugin when you need framework-lifecycle control, external integration bootstrapping, or behavior that does not fit the package/domain/entity shell. The example below builds a product catalog plugin from scratch.

A catalog plugin that:

  • Mounts GET /catalog/products and POST /catalog/products
  • Publishes entity:products.created when a product is added
  • Requires the caller to be authenticated
  • Stores its state in pluginState for other plugins to read
src/plugins/catalog/
index.ts ← createCatalogPlugin() — the factory
router.ts ← HTTP routes
store.ts ← in-memory store (swap for a DB later)
types.ts ← shared types

The store is a plain factory — closure-owned state, no singletons:

src/plugins/catalog/store.ts
export interface Product {
id: string;
name: string;
price: number;
createdAt: string;
}
export function createProductStore() {
const products = new Map<string, Product>();
return {
list(): Product[] {
return Array.from(products.values());
},
get(id: string): Product | undefined {
return products.get(id);
},
create(data: { name: string; price: number }): Product {
const product: Product = {
id: crypto.randomUUID(),
name: data.name,
price: data.price,
createdAt: new Date().toISOString(),
};
products.set(product.id, product);
return product;
},
};
}
export type ProductStore = ReturnType<typeof createProductStore>;

The router receives the store and registry-backed event publisher — no globals, no imports from outer scope:

src/plugins/catalog/router.ts
import { createRoute, createRouter, errorResponse, getActorId } from '@lastshotlabs/slingshot-core';
import type { SlingshotEvents } from '@lastshotlabs/slingshot-core';
import type { Product, ProductStore } from './store';
declare module '@lastshotlabs/slingshot-core' {
interface SlingshotEventMap {
'entity:products.created': { product: Product; userId: string };
}
}
export function createCatalogRouter(store: ProductStore, events: SlingshotEvents) {
const router = createRouter();
router.openapi(
createRoute({
method: 'get',
path: '/catalog/products',
tags: ['Catalog'],
responses: {
200: {
description: 'List of products',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
products: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
price: { type: 'number' },
createdAt: { type: 'string' },
},
required: ['id', 'name', 'price', 'createdAt'],
},
},
},
required: ['products'],
},
},
},
},
},
}),
async c => {
return c.json({ products: store.list() }) as never;
},
);
router.openapi(
createRoute({
method: 'post',
path: '/catalog/products',
tags: ['Catalog'],
request: {
body: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
price: { type: 'number', exclusiveMinimum: 0 },
},
required: ['name', 'price'],
},
},
},
},
},
responses: {
201: {
description: 'Created product',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
price: { type: 'number' },
createdAt: { type: 'string' },
},
required: ['id', 'name', 'price', 'createdAt'],
},
},
},
},
401: { description: 'Not authenticated' },
},
}),
async c => {
const userId = getActorId(c);
if (!userId) return errorResponse(c, 'Unauthorized', 401);
const body = (await c.req.json()) as { name: string; price: number };
const product = store.create(body);
// Emit to the event bus — other plugins and SSE clients can subscribe
events.publish(
'entity:products.created',
{ product, userId },
{ userId, actorId: userId, source: 'http', requestTenantId: null },
);
return c.json(product, 201) as never;
},
);
return router;
}

A plugin runs in four phases. setupMiddleware fires first (before tenant middleware). setupRoutes fires after tenant middleware — handlers can read the resolved actor and other request context values. setupPost fires last, after all routes register — use it for subscriptions and other true post-assembly work. pluginState is not a setupPost-only concept: publish it in the earliest phase where the state is actually canonical.

src/plugins/catalog/index.ts
import {
type SlingshotPlugin,
defineEvent,
getContext,
publishPluginState,
} from '@lastshotlabs/slingshot-core';
import { createCatalogRouter } from './router';
import { createProductStore } from './store';
export function createCatalogPlugin(): SlingshotPlugin {
// Store is created once per plugin instance — no shared state
const store = createProductStore();
return {
name: 'catalog',
dependencies: ['slingshot-auth'], // auth must run before catalog
async setupMiddleware({ events }) {
events.register(
defineEvent('entity:products.created', {
ownerPlugin: 'catalog',
exposure: ['client-safe'],
resolveScope: (_payload, ctx) => ({
userId: ctx.userId ?? null,
actorId: ctx.actorId ?? null,
}),
}),
);
},
async setupRoutes({ app, events }) {
// Routes are mounted after tenant middleware — they get the resolved actor on context
const ctx = getContext(app);
publishPluginState(ctx.pluginState, 'catalog', { store });
app.route('/', createCatalogRouter(store, events));
},
async setupPost({ bus }) {
// Subscribe to product events — log them for now
bus.on('entity:products.created', async ({ product }) => {
console.log(`Product created: ${product.name} ($${product.price})`);
});
},
};
}
app.config.ts
// @skip-typecheck
import { defineApp } from '@lastshotlabs/slingshot';
import { createAuthPlugin } from '@lastshotlabs/slingshot-auth';
import { createCatalogPlugin } from './plugins/catalog';
export default defineApp({
port: 3000,
security: {
signing: { secret: process.env.JWT_SECRET! },
},
plugins: [
createAuthPlugin({
auth: { roles: ['user'], defaultRole: 'user' },
db: { auth: 'sqlite', sessions: 'sqlite', oauthState: 'sqlite' },
}),
createCatalogPlugin(),
],
});
Terminal window
# List products (no auth required)
curl http://localhost:3000/catalog/products
# Create a product (requires auth)
curl -X POST http://localhost:3000/catalog/products \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <jwt>' \
-d '{ "name": "Widget", "price": 9.99 }'

entity:products.created is already registered as a client-safe event. Add an SSE endpoint and browser clients receive it automatically:

app.config.ts (add to existing)
{
"sse": {
"endpoints": {
"/__sse": { "auth": true }
}
}
}
// Browser: subscribe to catalog events
const es = new EventSource('/__sse', { withCredentials: true });
es.addEventListener('entity:products.created', e => {
const { product } = JSON.parse(e.data);
console.log('New product:', product.name);
});

If you also need a plugin-owned WebSocket hook, declare the endpoint in the root ws.endpoints config and then patch the seeded draft during setupPost:

import { getContext } from '@lastshotlabs/slingshot-core';
const plugin = {
async setupPost({ app }: { app: object }) {
const ctx = getContext(app);
const endpoint = ctx.wsEndpoints?.['/__ws/catalog'];
if (!endpoint) return;
endpoint.incoming = {
'catalog:ping': {
handler: async (ws, _payload, _context) => {
const socket = ws as { send(message: string): void };
socket.send(JSON.stringify({ event: 'catalog:pong' }));
},
},
};
},
};
void plugin;

That draft is the same endpoint map createServer() later boots, so the handler is live once the server starts. createApp() also seeds the same draft during tests and non-server bootstrap, so plugin WebSocket registration code does not need a separate path outside live server startup.

Cross-plugin communication goes through pluginState. If catalog isn’t installed, the check returns undefined — no hard dependency. Publish state when it becomes canonical, then read it from a later phase or a request handler:

import { getContext } from '@lastshotlabs/slingshot-core';
interface ProductStore {
list(): Array<{ id: string; name: string; price: number; createdAt: string }>;
}
// In another plugin's setupRoutes, setupPost, or a route handler:
declare const app: object;
const ctx = getContext(app);
const catalogState = ctx.pluginState.get('catalog') as { store: ProductStore } | undefined;
if (catalogState) {
const products = catalogState.store.list();
}

Framework-managed plugins should put real work in setupMiddleware, setupRoutes, and setupPost. The optional setup() hook is only a standalone convenience for plain Hono integration and is not part of the createApp() or createServer() lifecycle.