Skip to content

SlingshotContext

If you are learning the framework core, start with App Authoring: Context and Request Model.

This page is the lower-level authoring reference for plugin and framework work.

SlingshotContext is the per-app runtime container. It replaces module-level singletons with instance-owned state. Every createApp() call gets its own context — zero cross-app pollution.

import { getContext } from '@lastshotlabs/slingshot-core';
declare const app: object;
const ctx = getContext(app);

getContext throws if no context is attached. Use getContextOrNull when context may not exist:

import { getContextOrNull } from '@lastshotlabs/slingshot-core';
declare const app: object;
const ctx = getContextOrNull(app);
if (ctx) {
void ctx.config;
}
interface SlingshotContext {
app: object; // the Hono instance (typed as object to avoid Hono version pinning)
config: SlingshotResolvedConfig; // frozen resolved config
bus: SlingshotEventBus; // cross-package event bus
pluginState: Map<string, unknown>; // plugin-published runtime state
plugins: readonly SlingshotPlugin[]; // registered plugins in boot order
// DB handles — present when configured
redis: unknown | null; // ioredis client
mongo: { auth: unknown | null; app: unknown | null } | null; // Mongoose connections
sqlite: string | null; // path to the SQLite file
sqliteDb: RuntimeSqliteDatabase | null; // open SQLite handle
// Identity
identityResolver: IdentityResolver; // maps extracted identity input to Actor
actorResolver: RequestActorResolver | null; // resolves Actor from raw HTTP (WS/SSE upgrades)
// WS — populated by createServer() after boot, null during plugin phases
ws: WsState | null;
wsEndpoints: Record<string, unknown> | null; // bootstrap endpoint draft when ws config is supplied
// Internals
clear(): Promise<void>; // reset in-memory state (test isolation)
destroy(): Promise<void>; // close connections (graceful shutdown)
}

ctx.ws is still null during plugin phases because no server exists yet, but ctx.wsEndpoints can be present during setupPost when the caller passed a root ws config to createApp() or createServer().

That endpoint map is the bootstrap draft the server will later use. Framework-managed plugins can write incoming, onRoomSubscribe, and other endpoint hooks onto it during setupPost:

setupPost({ app }) {
const ctx = getContext(app);
if (!ctx.wsEndpoints) return;
const endpoint = ctx.wsEndpoints['/chat'] ??= {};
endpoint.incoming = {
ping: {
handler: (_ws, payload) => ({ echoed: payload }),
},
};
}

createServer() then boots from that same endpoint map. This keeps plugin WS wiring testable through createApp() while preserving the live behavior when a real server starts.

Plugins publish their runtime via pluginState. Read it in setupPost after dependencies have run:

import { createPluginStateMap, publishPluginState } from '@lastshotlabs/slingshot-core';
type AuthRuntime = {
verifyToken(token: string): Promise<unknown>;
getUserById(userId: string): Promise<{ id: string } | null>;
config: { issuer: string };
};
const ctx = { pluginState: createPluginStateMap() };
const authRuntimeForPublish: AuthRuntime = {
verifyToken: async () => null,
getUserById: async id => ({ id }),
config: { issuer: 'https://app.example.com' },
};
const userId = 'user_123';
// Publishing (in the auth plugin's setupRoutes)
publishPluginState(ctx.pluginState, 'auth.runtime', {
verifyToken: authRuntimeForPublish.verifyToken,
getUserById: authRuntimeForPublish.getUserById,
config: authRuntimeForPublish.config,
});
// Consuming (in your plugin's setupPost — auth is declared as a dependency)
const authRuntime = ctx.pluginState.get('auth.runtime') as AuthRuntime | undefined;
if (!authRuntime) throw new Error('auth plugin is required');
const user = await authRuntime.getUserById(userId);

DB handles are available on both ctx (the context) and config (the framework config passed to lifecycle methods). The config object is simpler to use inside lifecycle methods:

setupRoutes(app, config) {
const ctx = getContext(app);
// Each handle is only present if the corresponding DB is configured
const redis = ctx.redis; // ioredis client or null
const mongo = ctx.mongo; // { auth, app } or null
const sqliteDb = ctx.sqliteDb; // open Database handle or null
if (!sqliteDb) throw new Error('This plugin requires SQLite (config.db.sqlite)');
const adapter = createMyAdapter(sqliteDb);
publishPluginState(ctx.pluginState, 'myplugin.adapter', adapter);
}

Avoid reaching for DB handles directly in most cases. Use resolveRepo from slingshot-core to dispatch to the right adapter based on the configured storeType:

import { publishPluginState, resolveRepo } from '@lastshotlabs/slingshot-core';
import type { RepoFactories, StoreInfra, StoreType } from '@lastshotlabs/slingshot-core';
interface NotesRepo {
list(): Promise<unknown[]>;
}
declare const factories: RepoFactories<NotesRepo>;
declare const storeType: StoreType;
declare const infra: StoreInfra;
const adapter = resolveRepo(factories, storeType, infra);
const ctx = getContext(app);
// Subscribe in setupPost
ctx.bus.on('auth:user.created', async payload => {
await sendWelcomeEmail(payload.email);
});
// Emit from anywhere
ctx.bus.emit('note.created', { userId, noteId });
// Register events for client-safe delivery to SSE clients
ctx.events.register(defineEvent('note.created', { owner: 'notes', exposure: ['client-safe'] }));
ctx.events.register(defineEvent('note.updated', { owner: 'notes', exposure: ['client-safe'] }));
ctx.events.register(defineEvent('note.deleted', { owner: 'notes', exposure: ['client-safe'] }));

The app config is frozen at startup. Read it from context rather than prop-drilling it through your plugin factory:

setupRoutes(app) {
const ctx = getContext(app);
const cors = ctx.config.security.cors;
const storeType = ctx.config.resolvedStores.sessions;
}

ctx.clear() resets all in-memory state between tests without tearing down DB connections:

import { afterEach, describe, expect, test } from 'bun:test';
import { createApp } from '@lastshotlabs/slingshot';
import type { SlingshotPlugin } from '@lastshotlabs/slingshot-core';
import { getContext } from '@lastshotlabs/slingshot-core';
const myPlugin: SlingshotPlugin = { name: 'my-plugin' };
const { app } = await createApp({ plugins: [myPlugin] });
describe('my plugin', () => {
afterEach(async () => {
await getContext(app).clear(); // reset state between tests
});
test('does something', async () => {
const res = await app.request('/my-route', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ok: true }),
});
expect(res.status).toBe(200);
});
});

ctx.destroy() closes connections — call it in afterAll if you’re using real databases:

afterAll(async () => {
await getContext(app).destroy();
});

Reading context before the app is ready. getContext is only safe after createApp() or createServer() resolves. Call it at module level or during import and you’ll get a throw — the WeakMap entry doesn’t exist yet.

Stashing context in a module variable. If you run two createApp() instances in the same process — common in tests — each gets its own context. Store one in a module-level variable and you’ve silently wired both apps to the same state. Always call getContext(app) at the point of use.

Missing DB handle with a silent failure. If ctx.sqliteDb is null but your adapter requires it, don’t let it blow up somewhere deep in your code. Throw early with the expected config key: throw new Error('This plugin requires a SQLite path at config.db.sqlite'). The user needs to know what to fix.