Skip to content

Context and Request Model

Slingshot separates app-scoped state (things that exist for the lifetime of the process) from request-scoped state (things that exist for one HTTP request). Understanding the split makes everything from testing to middleware to realtime click.

SlingshotContext is the runtime state for one assembled app instance. Access it with getContext(app):

// @skip-typecheck
import { createApp, getContext } from '@lastshotlabs/slingshot';
const { app } = await createApp({ packages: [blogPackage] });
const ctx = getContext(app);
PropertyWhat it is
ctx.configThe frozen, resolved app config
ctx.eventsThe event bus — publish, on, off
ctx.persistenceDatabase and storage handles
ctx.pluginsRegistered plugin runtime state
ctx.wsWebSocket room state and broadcast helpers
ctx.clear()Resets in-memory state (for tests)
ctx.destroy()Tears down connections and runtime (cleanup)

Plugins get the context in their lifecycle hooks:

// @skip-typecheck
const analyticsPlugin: SlingshotPlugin = {
name: 'analytics',
setupPost({ ctx }) {
// Access the event bus from app context
ctx.events.on('auth:user.created', async ({ userId }) => {
await trackSignup(userId);
});
},
teardown({ ctx }) {
// Clean up on shutdown
console.log('Analytics shutting down');
},
};
// @skip-typecheck
import { createApp, getContext } from '@lastshotlabs/slingshot';
let app: Awaited<ReturnType<typeof createApp>>['app'];
let ctx: ReturnType<typeof getContext>;
beforeEach(async () => {
const result = await createApp({ packages: [blogPackage] });
app = result.app;
ctx = getContext(app);
});
afterEach(async () => {
await ctx.destroy();
});
test('creates a post', async () => {
const res = await app.request('/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Test Post' }),
});
expect(res.status).toBe(201);
});
test('state is isolated between tests', async () => {
// ctx.clear() is called implicitly — each test starts fresh
const res = await app.request('/posts');
const data = await res.json();
expect(data.items).toHaveLength(0);
});

Every HTTP request has an identity — who is making it. In Slingshot, that is the actor.

Package route handlers receive the actor directly:

// @skip-typecheck
route.get({
path: '/dashboard',
auth: 'userAuth',
handler: ({ actor, respond }) => {
return respond.json({
userId: actor.id,
roles: actor.roles,
tenantId: actor.tenantId,
});
},
});

Use getActor(c) and related helpers:

// @skip-typecheck
import { getActor, getActorId, getRequestTenantId } from '@lastshotlabs/slingshot-core';
// Full actor object
const actor = getActor(c);
// actor.id — user ID (or null for anonymous)
// actor.kind — 'user', 'service', 'anonymous'
// actor.roles — role strings
// actor.tenantId — the actor's home tenant
// actor.sessionId — current session
// actor.claims — JWT claims
// Just the user ID
const userId = getActorId(c);
// The request-scoped tenant (from URL, header, or resolver)
const tenantId = getRequestTenantId(c);
interface Actor {
id: string | null;
kind: 'user' | 'service' | 'anonymous';
roles?: string[];
tenantId?: string;
sessionId?: string;
claims?: Record<string, unknown>;
}
You need…Read from
Who is making this requestactor or getActor(c)
Just the user IDgetActorId(c)
The current tenantgetRequestTenantId(c)
Route params, query string, request bodyparams, query, body
Event busctx.events (app-scoped)
Database handlesctx.persistence
WebSocket roomsctx.ws
App configctx.config (frozen)
  • Do not stash ctx in a module-global variable. Use getContext(app) for the specific app instance. This matters when running multiple app instances in one process (common in tests).

  • Do not treat request identity as app state. The actor belongs to the request, not the app. If you need to correlate across requests, use event payloads or database state.

  • Do not mutate ctx.config. It is frozen after assembly. If you need dynamic config, use a secret provider or environment variables at boot time.