Add a new Hono API route or server-side utility. Use when adding endpoints, handlers, reusable server logic, validation helpers, or data transformation functions.
All code must follow the Coding Principles in AGENTS.md (functional, minimal, readable, modular).
src/server/index.tssrc/server/routes/{feature-name}.ts and import into src/server/index.tssrc/server/lib/{feature-name}.ts/api/kebab-case/internal/menu/action-name/internal/on-event-nameimport { Hono } from 'hono'
import type { Context } from 'hono'
import { context, redis, reddit } from '@devvit/web/server'
const HTTP_STATUS_BAD_REQUEST = 400
const HTTP_STATUS_FORBIDDEN = 403
const HTTP_STATUS_INTERNAL_ERROR = 500
const myHandler = async (c: Context): Promise<Response> => {
try {
const userId = requireUserId()
const result = await doThing()
return c.json({ status: 'success', data: result })
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return c.json({ status: 'error', message }, HTTP_STATUS_INTERNAL_ERROR)
}
}
app.get('/api/my-route', myHandler)
// Success: { status: 'success', data: { ... } }
// Error: { status: 'error', message: 'Human-readable string' }
// Menu navigation: { navigateTo: 'https://reddit.com/...' }
type SuccessResponse<T> = { status: 'success'; data: T }
type ErrorResponse = { status: 'error'; message: string }
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse
Extract these into src/server/lib/context-guards.ts when used across multiple routes:
import { context } from '@devvit/web/server'
export const requireUserId = (): string => {
const { userId } = context
if (!userId) throw new Error('User must be logged in')
return userId
}
export const requirePostId = (): string => {
const { postId } = context
if (!postId) throw new Error('Must be in a post context')
return postId
}
For reusable server logic in src/server/lib/:
export const parseRedisNumber = (value: string | undefined, fallback: number): number => {
if (value === undefined) return fallback
const parsed = parseInt(value, 10)
return Number.isNaN(parsed) ? fallback : parsed
}
export const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) return error.message
return 'Unknown error'
}
Every /api/* endpoint is callable by any Reddit user who loads the post. Treat all client input as untrusted.
const handler = async (c: Context): Promise<Response> => {
const body = await c.req.json().catch(() => null)
if (!body || typeof body !== 'object') {
return c.json({ status: 'error', message: 'Invalid request body' }, HTTP_STATUS_BAD_REQUEST)
}
// Validate each field — never spread or destructure blindly
const { guess } = body as Record<string, unknown>
if (typeof guess !== 'string' || guess.length === 0 || guess.length > 200) {
return c.json({ status: 'error', message: 'Invalid guess' }, HTTP_STATUS_BAD_REQUEST)
}
}
| ❌ Never | ✅ Instead |
|---|---|
Use await c.req.json() then destructure directly | Parse with .catch(() => null), check shape |
Trust typeof x === 'string' alone | Also check length bounds |
| Accept numeric input as-is | parseInt + Number.isNaN + range check |
| Pass raw user input into Redis keys | Build keys from validated/server-sourced values only |
type SubmitGuessInput = { guess: string; difficulty: 'easy' | 'medium' | 'hard' }
const VALID_DIFFICULTIES = ['easy', 'medium', 'hard'] as const
const parseSubmitGuess = (raw: unknown): SubmitGuessInput | null => {
if (!raw || typeof raw !== 'object') return null
const obj = raw as Record<string, unknown>
const { guess, difficulty } = obj
if (typeof guess !== 'string' || guess.length === 0 || guess.length > 500) return null
if (typeof difficulty !== 'string') return null
if (!VALID_DIFFICULTIES.includes(difficulty as typeof VALID_DIFFICULTIES[number])) return null
return { guess, difficulty: difficulty as SubmitGuessInput['difficulty'] }
}
// Usage:
const input = parseSubmitGuess(await c.req.json().catch(() => null))
if (!input) {
return c.json({ status: 'error', message: 'Invalid input' }, HTTP_STATUS_BAD_REQUEST)
}
context.userId// ❌ WRONG: client tells us who they are
const { userId } = await c.req.json()
// ✅ RIGHT: server knows who they are
const { userId } = context
const owner = await redis.hGet(`game:${postId}:meta`, 'creatorId')
if (owner !== userId) {
return c.json({ status: 'error', message: 'Not authorized' }, HTTP_STATUS_FORBIDDEN)
}
context.* values or validated/bounded strings in keys: — it's your key delimiter/^[a-zA-Z0-9]{4,8}$/setInterval, no long-running processes (30s request timeout)media.upload()src/server/__tests__/ using bun:test and devvit-mocksinstanceof Error narrowingcontext.userId / context.postId guarded before use.catch(() => null) and shape-validatedcontext.userId, never from request bodybun run test passes with zero failures