feat(server): mount Hono runtime via Next catch-all + dist-loading client

This commit is contained in:
Innei
2026-06-10 02:40:39 +08:00
parent 62fd57c58b
commit ae63ff9118
3 changed files with 171 additions and 0 deletions
@@ -0,0 +1,29 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest';
import { GET } from './route';
describe('Next Hono binding route', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it('forwards the rewritten request path into the configured Hono dev runtime', async () => {
vi.stubEnv('NODE_ENV', 'development');
vi.stubEnv('LOBE_DEV_HONO_TARGET', 'http://localhost:3011');
const fetchSpy = vi.fn(async (request: Request) => {
expect(request.url).toBe('http://localhost:3011/api/version');
return Response.json({ version: '2.1.56' });
});
vi.stubGlobal('fetch', fetchSpy);
const response = await GET(new Request('http://localhost:3010/hono-runtime/api/version'));
expect(response.status).toBe(200);
expect(response.headers.get('x-lobe-dev-hono-binding')).toBe('next-catch-all');
await expect(response.json()).resolves.toEqual({ version: '2.1.56' });
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,47 @@
import { fetchHonoRuntime } from '@/server/hono-runtime/client';
const HONO_BINDING_PREFIX = '/hono-runtime';
const HONO_BINDING_HEADER = 'x-lobe-dev-hono-binding';
const rewriteHonoBindingRequest = (request: Request) => {
const url = new URL(request.url);
if (!url.pathname.startsWith(HONO_BINDING_PREFIX)) return request;
const pathname = url.pathname.slice(HONO_BINDING_PREFIX.length);
url.pathname = pathname || '/';
const init: RequestInit & { duplex?: 'half' } = {
headers: request.headers,
method: request.method,
redirect: request.redirect,
signal: request.signal,
};
if (request.method !== 'GET' && request.method !== 'HEAD') {
init.body = request.body;
init.duplex = 'half';
}
return new Request(url, init);
};
const handler = async (request: Request) => {
const response = await fetchHonoRuntime(rewriteHonoBindingRequest(request));
const headers = new Headers(response.headers);
headers.set(HONO_BINDING_HEADER, 'next-catch-all');
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
};
export const DELETE = handler;
export const GET = handler;
export const HEAD = handler;
export const OPTIONS = handler;
export const PATCH = handler;
export const POST = handler;
export const PUT = handler;
+95
View File
@@ -0,0 +1,95 @@
import path from 'node:path';
interface HonoFetchApp {
fetch: (request: Request) => Promise<Response> | Response;
}
interface HonoDistModule {
default?: unknown;
}
let productionHonoApp: HonoFetchApp | undefined;
const isHonoFetchApp = (value: unknown): value is HonoFetchApp =>
typeof value === 'object' &&
value !== null &&
'fetch' in value &&
typeof value.fetch === 'function';
const createForwardRequest = (request: Request, url: URL) => {
const headers = new Headers(request.headers);
headers.delete('host');
const init: RequestInit & { duplex?: 'half' } = {
headers,
method: request.method,
redirect: request.redirect,
signal: request.signal,
};
if (request.method !== 'GET' && request.method !== 'HEAD') {
init.body = request.body;
init.duplex = 'half';
}
return new Request(url, init);
};
interface ModuleLoader {
createRequire?: (filename: string) => (id: string) => unknown;
}
interface ProcessWithBuiltinModule {
getBuiltinModule?: (id: string) => unknown;
}
const loadExternalModule = (entry: string) => {
// Resolve the require() factory through process.getBuiltinModule at runtime so the
// separately built Hono dist stays opaque to the Next bundler and is never compiled in.
const moduleLoader = (process as ProcessWithBuiltinModule).getBuiltinModule?.('node:module') as
| ModuleLoader
| undefined;
const runtimeRequire = moduleLoader?.createRequire?.(path.join(process.cwd(), 'package.json'));
if (!runtimeRequire) {
throw new TypeError('Runtime require is not available for the Hono dist entry');
}
return runtimeRequire(entry);
};
const loadProductionHonoApp = () => {
if (productionHonoApp) return productionHonoApp;
const entry =
process.env.LOBE_HONO_DIST_ENTRY || path.join(process.cwd(), 'apps/server/dist/index.js');
const module = loadExternalModule(entry) as HonoDistModule | HonoFetchApp;
const app = isHonoFetchApp(module)
? module
: isHonoFetchApp(module.default)
? module.default
: undefined;
if (!app) {
throw new TypeError(`Hono dist entry does not export a fetch-compatible app: ${entry}`);
}
productionHonoApp = app;
return app;
};
export const fetchHonoRuntime = async (request: Request) => {
const devTarget = process.env.LOBE_DEV_HONO_TARGET;
if (process.env.NODE_ENV !== 'production' && devTarget) {
const sourceUrl = new URL(request.url);
const targetUrl = new URL(devTarget);
targetUrl.pathname = sourceUrl.pathname;
targetUrl.search = sourceUrl.search;
return fetch(createForwardRequest(request, targetUrl));
}
return loadProductionHonoApp().fetch(request);
};