hono icon indicating copy to clipboard operation
hono copied to clipboard

Make `Context` class officially accessible

Open Digital39999 opened this issue 3 months ago • 10 comments

What is the feature you are proposing?

Expose currently internal class Context, to enable advanced request/response handling.

Developers often need to construct or manipulate context objects directly for testing, unified request handling (e.g., WebSocket + HTTP), or extensions.

Current workaround is copying code from the repo.

Officially exporting it (with a disclaimer such as “unstable” or “subject to change”) avoids duplication and aligns with how other libraries expose internals.

Digital39999 avatar Sep 16 '25 00:09 Digital39999

Hi @Digital39999

Exporting Context class and adding LinearRouter.matchRoute static methods are different things. Can you separate them?

yusukebe avatar Sep 16 '25 08:09 yusukebe

Exporting Context class and adding LinearRouter.matchRoute static methods are different things. Can you separate them?

Totally understand the desire to keep PRs focused. At the same time, both changes are relatively small, independent, and valid features that don’t overlap in implementation. Splitting them at this stage would add some overhead.

That said, if it’s really necessary, I can separate them into two PRs (or two issues?).

Digital39999 avatar Sep 16 '25 08:09 Digital39999

@Digital39999

Please separate them into two issues. I think we should discuss them separately.

yusukebe avatar Sep 16 '25 08:09 yusukebe

@yusukebe I’ve separated them into two issues. Let me know what you think.

Digital39999 avatar Sep 16 '25 09:09 Digital39999

@Digital39999 Thanks!

yusukebe avatar Sep 17 '25 05:09 yusukebe

@Digital39999

Developers often need to construct or manipulate context objects directly for testing, unified request handling (e.g., WebSocket + HTTP), or extensions.

Can you provide a concrete code (pseudo-code is okay)? I'm thinking of this issue, but I want to know the use cases for it more deeply.

yusukebe avatar Sep 18 '25 06:09 yusukebe

@yusukebe

Here are two concrete use cases where I would need this (might be a little overengineered):

Use case 1: WebSocket + HTTP integration (per route)
import { Hono, Context, Next } from 'hono';
import { serve } from '@hono/node-server';
import { IncomingMessage } from 'http';
import { WebSocketServer } from 'ws';
import { Duplex } from 'stream';
import crypto from 'crypto';

type Env = {
	Bindings: {
		incoming: IncomingMessage;
		outgoing: undefined;
	};
	Variables: {
		user: { id: number };
	};
};

type MiddlewareResult = { success: true; } | { success: false, response: Response; };
type RouteHandler = (c: Context<Env>, next: Next) => Promise<Response | void>;

const app = new Hono<Env>();
const wss = new WebSocketServer({ noServer: true });

const wsRouter = new Map<string, RouteHandler[]>();
const routeContext = new Map<string, Context<Env>>();

wss.on('connection', (ws, req) => {
	const context = routeContext.get(getKey(req));
	if (!context) return ws.close(1008, 'Unauthorized.');

	const user = context.get('user');
	ws.send(JSON.stringify(user));

	ws.on('message', (message) => {
		ws.send(`User ${user.id} sent: ${message}`);
	});
});

// Start server
const server = serve(app);
server.on('upgrade', handleUpgrade);

// Register HTTP route
app.get('/api/users', authMiddleware, (c) => {
	const user = c.get('user');
	return c.json(user);
});

// Register WebSocket route with SAME handlers
wsRouter.set('/api/users/debug', [authMiddleware]);

// Authentication middleware
async function authMiddleware(c: Context<Env>, next: Next) {
	const token = c.req.header('Authorization');
	if (!token || token !== 'valid-token') return c.json({ error: 'Unauthorized' }, 401);
	c.set('user', { id: 123 });
	return next();
}

// WebSocket connection handling
async function handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
	const url = new URL(request.url || '/', 'http://localhost');
	const pathname = url.pathname;

	const storedHandlers = wsRouter.get(pathname);
	if (!storedHandlers) {
		socket.end('HTTP/1.1 404 Not Found\r\n\r\n');
		return;
	}

	const headers = new Headers();
	for (const [key, value] of Object.entries(request.headers)) {
		if (value) {
			const headerValue = Array.isArray(value) ? value.join(', ') : value;
			headers.append(key, headerValue);
		}
	}

	const mockRequest = new Request(`http://localhost${request.url}`, { method: 'GET', headers });

	const context = new Context<Env>(mockRequest, { // This breaks - Context is only a type
		env: { incoming: request, outgoing: undefined },
		path: pathname,
	});

	// Reuse the EXACT same middleware chain as HTTP
	const middlewareResult = await executeWSMiddlewares(context, storedHandlers);

	if (!middlewareResult.success) {
		socket.write(`HTTP/1.1 ${middlewareResult.response.status} ${middlewareResult.response.statusText}` + '\r\n');
		const body = await middlewareResult.response.text();
		socket.end(body);
		return;
	}

	routeContext.set(getKey(request), context);

	// Actual upgrade to WebSocket
	wss.handleUpgrade(request, socket, head, (ws) => {
		wss.emit('connection', ws, request);
	});
}

async function executeWSMiddlewares(context: Context<Env>, middlewares: RouteHandler[]): Promise<MiddlewareResult> {
	for (const middleware of middlewares) {
		let nextCalled = false;
		let middlewareResult: Response | undefined;

		const next = async () => { nextCalled = true; };
		const result = await middleware(context, next);

		if (result instanceof Response) middlewareResult = result;
		if (!nextCalled && !middlewareResult) return { success: false, response: new Response('Middleware did not call next()', { status: 500 }) };
		if (middlewareResult && middlewareResult.status !== 200) return { success: false, response: middlewareResult };
	}

	return { success: true };
}

// This is just example logic
function getKey(request: IncomingMessage): string {
	const { method, url, headers } = request;
	return crypto.createHash('sha256').update(JSON.stringify({ method, url, headers })).digest('hex');
}

This allows reusing identical middleware/auth logic between HTTP and WebSocket routes.

Use case 2: Direct testing without server
import { Context, Next } from 'hono';

type TestEnv = {
	Variables: { user?: { id: number } };
};

// Middleware to test
const authMiddleware = (c: Context<TestEnv>, next: Next) => {
	const token = c.req.header('Authorization');
	if (!token) return c.json({ error: 'Unauthorized' }, 401);
	c.set('user', { id: 123 });
	return next();
};

function createTestContext(path: string, headers: Record<string, string> = {}) {
	const request = new Request('http://localhost' + path, { headers });
	return new Context<TestEnv>(request, { env: {}, path });
}

// Usage
const testCtx = createTestContext('/api/users', { 'Authorization': 'valid-token' });
authMiddleware(testCtx, async () => { });

const userData = testCtx.get('user');
console.log('Test passed:', userData?.id === 123);

This enables direct middleware testing without spinning up servers or complex mocking.

Additionally, there are other issues that would benefit from this: #4195, #3988.

Digital39999 avatar Sep 18 '25 10:09 Digital39999

+1

This is much needed for testing, a lot of people (including myself) don't test their endpoints with a server actually running.

fgcoelho avatar Sep 18 '25 21:09 fgcoelho

@Digital39999

Thank you for showing the use cases.

Regarding Use Case 2, please test the app that the middleware has applied. Like this.

import { Hono } from 'hono'
import { createMiddleware } from 'hono/factory'

type TestEnv = {
  Variables: { user?: { id: number } }
}

const authMiddleware = createMiddleware<TestEnv>(async (c, next) => {
  const token = c.req.header('Authorization')
  if (!token) return c.json({ error: 'Unauthorized' }, 401)
  c.set('user', { id: 123 })
  await next()
})

const app = new Hono()

app.get('/api/users/*', authMiddleware, (c) => {
  const user = c.get('user')
  return c.json(user)
})

const res = await app.request('/api/users', {
  headers: {
    Authorization: 'foo'
  }
})

console.log(await res.text())

This test never spins up the actual server. It is recommended in the document: https://hono.dev/docs/guides/testing. Please use this method.

Regarding Use Case 1. It's not the expected usage of hono. We can't follow it. And, I think you may do it with @hono/node-ws

yusukebe avatar Sep 19 '25 00:09 yusukebe

@yusukebe

I get that my examples aren’t the “expected usage” of Hono today, but that’s exactly the point of this issue, you don’t have to support them directly. hono-ws simply doesn't let me do what I intended to do in my routes handler, and there’s currently no other path except using the raw class.

Making Context constructible would give space for these kinds of experiments. Even if my examples are overengineered, it opens the door for new ideas in the ecosystem while keeping the recommended usage the same for everyone else.

Digital39999 avatar Sep 19 '25 08:09 Digital39999