diff --git a/README.md b/README.md index b863eda..6238dd5 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![codecov](https://codecov.io/gh/geek-fun/serverless-adapter/graph/badge.svg?token=lw1AJuX9S9)](https://codecov.io/gh/geek-fun/serverless-adapter) -Adapter for web frameworks (Express, Koa) to run on serverless platforms across multiple cloud providers with automatic provider detection. +Adapter for web frameworks (Express, Koa, Hono) to run on serverless platforms across multiple cloud providers with automatic provider detection. ## Supported Cloud Providers @@ -25,6 +25,10 @@ Adapter for web frameworks (Express, Koa) to run on serverless platforms across | Express | 5.x | ✅ Supported | | Koa | 2.x | ✅ Supported | | Koa | 3.x | ✅ Supported | +| Hono | 4.x | ✅ Supported | + +> **Note**: Hono support requires Node.js >= 18 (for Web API Request/Response globals). +> Express and Koa continue to support Node.js >= 16. ## Quick Start @@ -124,17 +128,31 @@ app.get('/api/users', (req, res) => { export const handler = serverlessAdapter(app, { provider: 'volcengine' }); ``` +#### Hono Example + +```typescript +import { Hono } from 'hono'; +import serverlessAdapter from '@geek-fun/serverless-adapter'; + +const app = new Hono(); + +app.get('/', (c) => c.json({ message: 'Hello from Hono!' })); + +// Auto-detect provider based on context +export const handler = serverlessAdapter(app); +``` + ## API Reference ### `serverlessAdapter(app, options?)` -Creates a serverless handler for your Express or Koa application. +Creates a serverless handler for your Express, Koa, or Hono application. #### Parameters | Parameter | Type | Required | Description | | ------------------ | --------------------------------------- | -------- | ------------------------------------------------------------ | -| `app` | `Express \| Koa` | Yes | Express or Koa application instance | +| `app` | `Express \| Koa \| Hono` | Yes | Express, Koa, or Hono application instance | | `options.provider` | `'aliyun' \| 'tencent' \| 'volcengine'` | No | Explicitly specify cloud provider (auto-detected if omitted) | #### Returns diff --git a/package-lock.json b/package-lock.json index 8397b9e..a5dbed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "express4": "npm:express@^4.22.1", "express5": "npm:express@^5.2.1", "globals": "^15.15.0", + "hono": "^4.6.0", "husky": "^9.1.7", "jest": "^29.7.0", "koa-body": "^6.0.1", @@ -4265,6 +4266,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", diff --git a/package.json b/package.json index ae8d66d..710bf2a 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "express4": "npm:express@^4.22.1", "express5": "npm:express@^5.2.1", "globals": "^15.15.0", + "hono": "^4.6.0", "husky": "^9.1.7", "jest": "^29.7.0", "koa-body": "^6.0.1", diff --git a/src/framework.ts b/src/framework.ts index c5dad10..567423a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -12,9 +12,70 @@ const callableFn = (callback: (req: any, res: any) => Promise) => { }; }; +const honoHandler = (app: { fetch: (req: Request) => Response | Promise }) => { + return async (request: ServerlessRequest): Promise => { + const url = new URL(request.url || '/', 'http://localhost'); + const method = (request.method || 'GET').toUpperCase(); + + const headers = new Headers(); + for (const [key, value] of Object.entries(request.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const v of value) headers.append(key, String(v)); + } else { + headers.set(key, String(value)); + } + } + + const hasBody = method !== 'GET' && method !== 'HEAD' && request.body !== undefined; + const webRequest = new Request(url, { + method, + headers, + body: hasBody ? (request.body as BodyInit) : undefined, + }); + + const webResponse = await app.fetch(webRequest); + + const response = new ServerlessResponse(request); + response.statusCode = webResponse.status; + + // Collect set-cookie values across Node 18 (no getSetCookie) and Node 20+ + const hasGetSetCookie = typeof (webResponse.headers as Headers).getSetCookie === 'function'; + const setCookieValues: string[] = []; + if (hasGetSetCookie) { + const cookies = (webResponse.headers as Headers).getSetCookie(); + if (cookies) setCookieValues.push(...cookies); + } + + webResponse.headers.forEach((value, key) => { + if (key.toLowerCase() === 'set-cookie') { + // Node 18 fallback: getSetCookie not available, collect from forEach + if (!hasGetSetCookie) setCookieValues.push(value); + return; + } + response.setHeader(key, value); + }); + + // Single-value → string (included in headers); multi-value → array (in multiValueHeaders only) + if (setCookieValues.length === 1) { + response.setHeader('set-cookie', setCookieValues[0]); + } else if (setCookieValues.length > 1) { + response.setHeader('set-cookie', setCookieValues); + } + + const bodyBuffer = Buffer.from(await webResponse.arrayBuffer()); + response.end(bodyBuffer); + + return response; + }; +}; + // eslint-disable-next-line export const constructFramework = (app: any) => { - if (typeof app.callback === 'function') { + if (typeof app.fetch === 'function') { + // Hono (Web API standard) + return honoHandler(app); + } else if (typeof app.callback === 'function') { // Koa return callableFn(app.callback()); } else if (typeof app === 'function') { diff --git a/src/index.ts b/src/index.ts index 64dde6b..2c3c3ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,13 @@ import { constructFramework } from './framework'; import { waitForStreamComplete, buildResponse } from './transport'; import { detectProvider, getProvider } from './providers'; import { debug } from './common'; -import { CloudProvider, ProviderEvent, ProviderContext, ServerlessResponse } from './types'; +import { + CloudProvider, + HonoApp, + ProviderEvent, + ProviderContext, + ServerlessResponse, +} from './types'; export interface ServerlessAdapterOptions { provider?: CloudProvider; @@ -16,12 +22,13 @@ type HandlerResult = { body: string; headers: IncomingHttpHeaders; isBase64Encoded: boolean; + multiValueHeaders?: { [key: string]: string[] }; }; type Handler = (event: ProviderEvent, context: ProviderContext) => Promise; const serverlessAdapter = ( - app: Express | Application, + app: Express | Application | HonoApp, options?: ServerlessAdapterOptions, ): Handler => { const serverlessFramework = constructFramework(app); diff --git a/src/providers/aliyun.ts b/src/providers/aliyun.ts index f2b4e3a..0830a54 100644 --- a/src/providers/aliyun.ts +++ b/src/providers/aliyun.ts @@ -30,6 +30,9 @@ export class AliyunProvider extends BaseProvider { body: response.body, headers: response.headers, isBase64Encoded: response.isBase64Encoded, + multiValueHeaders: (response as Record).multiValueHeaders as + | { [key: string]: string[] } + | undefined, }; } diff --git a/src/providers/tencent.ts b/src/providers/tencent.ts index 5a0737c..5ba0256 100644 --- a/src/providers/tencent.ts +++ b/src/providers/tencent.ts @@ -34,6 +34,9 @@ export class TencentProvider extends BaseProvider { statusCode: response.statusCode, headers: response.headers, body: response.body, + multiValueHeaders: (response as Record).multiValueHeaders as + | { [key: string]: string[] } + | undefined, }; } diff --git a/src/providers/volcengine.ts b/src/providers/volcengine.ts index 85db478..a27f9aa 100644 --- a/src/providers/volcengine.ts +++ b/src/providers/volcengine.ts @@ -33,6 +33,9 @@ export class VolcengineProvider extends BaseProvider { statusCode: response.statusCode, headers: response.headers, body: response.body, + multiValueHeaders: (response as Record).multiValueHeaders as + | { [key: string]: string[] } + | undefined, }; } diff --git a/src/types/aliyun.ts b/src/types/aliyun.ts index 00abe6f..aad4d87 100644 --- a/src/types/aliyun.ts +++ b/src/types/aliyun.ts @@ -55,4 +55,5 @@ export interface AliyunResponse { body: string; headers: IncomingHttpHeaders; isBase64Encoded: boolean; + multiValueHeaders?: { [key: string]: string[] }; } diff --git a/src/types/index.ts b/src/types/index.ts index 185bd5a..e87dfed 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -39,6 +39,11 @@ export type Event = Buffer; /** * Unified Serverless Event format used internally */ +/** + * Minimal interface for Hono app detection via duck-typing + */ +export type HonoApp = { fetch: (request: Request) => Response | Promise }; + export type ServerlessEvent = { path: string; httpMethod: string; @@ -57,6 +62,7 @@ export type ServerlessResponse = { body: string; headers: IncomingHttpHeaders; isBase64Encoded: boolean; + multiValueHeaders?: { [key: string]: string[] }; }; /** @@ -74,7 +80,7 @@ export type ProviderContext = AliyunApiGatewayContext | TencentScfContext | Volc */ export type ProviderEvent = AliyunEvent | TencentEvent | VolcengineEvent; -export type ServerlessAdapter = (app: Express | Application) => ( +export type ServerlessAdapter = (app: Express | Application | HonoApp) => ( event: Event, context: Context, ) => Promise<{ @@ -82,4 +88,5 @@ export type ServerlessAdapter = (app: Express | Application) => ( body: string; headers: IncomingHttpHeaders; isBase64Encoded: boolean; + multiValueHeaders?: { [key: string]: string[] }; }>; diff --git a/src/types/tencent.ts b/src/types/tencent.ts index 917c317..8e2a1f6 100644 --- a/src/types/tencent.ts +++ b/src/types/tencent.ts @@ -56,6 +56,7 @@ export interface TencentScfResponse { statusCode: number; headers: IncomingHttpHeaders; body: string; + multiValueHeaders?: { [key: string]: string[] }; } /** diff --git a/src/types/volcengine.ts b/src/types/volcengine.ts index 6cb80f3..3b2ab55 100644 --- a/src/types/volcengine.ts +++ b/src/types/volcengine.ts @@ -52,6 +52,7 @@ export interface VolcengineVefaasResponse { statusCode: number; headers: IncomingHttpHeaders; body: string; + multiValueHeaders?: { [key: string]: string[] }; } /** diff --git a/tests/index-hono.spec.test.ts b/tests/index-hono.spec.test.ts new file mode 100644 index 0000000..607517c --- /dev/null +++ b/tests/index-hono.spec.test.ts @@ -0,0 +1,397 @@ +import { Hono } from 'hono'; +import { defaultContext, defaultEvent } from './fixtures/fcContext'; +import { sendRequest } from './fixtures/requestHelper'; + +describe('Hono with Aliyun provider', () => { + let app: Hono; + + beforeEach(() => { + app = new Hono(); + }); + + // 1. Happy path: JSON response + it('should return JSON response', async () => { + app.get('/api/test', (c) => c.json({ ok: true })); + + const response = await sendRequest(app, defaultEvent, defaultContext); + + expect(response.statusCode).toEqual(200); + expect(JSON.parse(response.body)).toEqual({ ok: true }); + }); + + // 2. Status code + text body + it('should set custom status code and return text body', async () => { + app.get('/api/test', (c) => { + c.status(418); + return c.text("I'm a teapot"); + }); + + const response = await sendRequest(app, defaultEvent, defaultContext); + + expect(response.statusCode).toEqual(418); + expect(response.body).toEqual("I'm a teapot"); + }); + + // 3. GET with single query param + it('should read single query parameter', async () => { + app.get('/api/test', (c) => c.text(c.req.query('foo') || '')); + + const response = await sendRequest( + app, + { + ...defaultEvent, + queryParameters: { foo: 'bar' }, + }, + defaultContext, + ); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual('bar'); + }); + + // 4. Multiple query params + it('should read all query parameters as JSON', async () => { + app.get('/api/test', (c) => c.json(c.req.query())); + + const response = await sendRequest( + app, + { + ...defaultEvent, + queryParameters: { foo: 'bar', baz: 'qux', num: '123' }, + }, + defaultContext, + ); + + expect(response.statusCode).toEqual(200); + expect(JSON.parse(response.body)).toEqual({ + foo: 'bar', + baz: 'qux', + num: '123', + }); + }); + + // 5. Path params + it('should extract path parameters from URL', async () => { + app.get('/users/:id', (c) => c.json({ id: c.req.param('id') })); + + const response = await sendRequest( + app, + { + ...defaultEvent, + path: '/users/123', + pathParameters: { id: '123' }, + }, + defaultContext, + ); + + expect(response.statusCode).toEqual(200); + expect(JSON.parse(response.body)).toEqual({ id: '123' }); + }); + + // 6. POST JSON body + it('should parse JSON request body', async () => { + app.post('/api', async (c) => { + const body = await c.req.json(); + return c.json(body); + }); + + const response = await sendRequest( + app, + { + ...defaultEvent, + path: '/api', + httpMethod: 'POST', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' }, + }, + defaultContext, + ); + + expect(response.statusCode).toEqual(200); + expect(JSON.parse(response.body)).toEqual({ hello: 'world' }); + }); + + // 7. POST text body + it('should read text request body', async () => { + app.post('/api', async (c) => { + const body = await c.req.text(); + return c.text(body); + }); + + const response = await sendRequest( + app, + { + ...defaultEvent, + path: '/api', + httpMethod: 'POST', + body: 'hello, world', + headers: { 'Content-Type': 'text/plain' }, + }, + defaultContext, + ); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual('hello, world'); + }); + + // 8. POST raw body (arrayBuffer) + it('should read raw request body as arrayBuffer', async () => { + app.post('/api', async (c) => { + const buf = await c.req.arrayBuffer(); + return c.text(new TextDecoder().decode(buf)); + }); + + const response = await sendRequest( + app, + { + ...defaultEvent, + path: '/api', + httpMethod: 'POST', + body: 'raw binary data', + headers: { 'Content-Type': 'application/octet-stream' }, + }, + defaultContext, + ); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual('raw binary data'); + }); + + // 9. Empty body GET + it('should handle request with undefined body', async () => { + app.get('/api/test', (c) => c.json({ ok: true })); + + const response = await sendRequest( + app, + { + ...defaultEvent, + body: undefined, + }, + defaultContext, + ); + + expect(response.statusCode).toEqual(200); + expect(JSON.parse(response.body)).toEqual({ ok: true }); + }); + + // 10. Base64-encoded request body + it('should decode base64-encoded request body', async () => { + // Handler appends "!" so we can prove Hono received decoded "hello" + // not the raw base64 "aGVsbG8=". The response body is then re-base64-encoded + // by buildResponse because request.isBase64Encoded is true. + app.post('/api', async (c) => { + const text = await c.req.text(); + return c.text(text + '!'); + }); + + const response = await sendRequest( + app, + { + ...defaultEvent, + path: '/api', + httpMethod: 'POST', + body: 'aGVsbG8=', // base64 of "hello" + isBase64Encoded: true, + headers: { 'Content-Type': 'text/plain' }, + }, + defaultContext, + ); + + // Hono received "hello", responded "hello!" + // buildResponse re-base64-encodes: Buffer.from("hello!").toString('base64') = "aGVsbG8h" + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual('aGVsbG8h'); + }); + + // 11. Custom response headers + it('should set custom response headers', async () => { + app.get('/api/test', (c) => { + c.header('X-Custom', 'value'); + return c.text('ok'); + }); + + const response = await sendRequest(app, defaultEvent, defaultContext); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual('ok'); + expect(response.headers['x-custom']).toBeDefined(); + expect(response.headers['x-custom']).toEqual('value'); + }); + + // 12. Multiple Set-Cookie headers + it('should handle multiple set-cookie headers via multiValueHeaders', async () => { + app.get('/api/test', (c) => { + c.header('Set-Cookie', 'a=1'); + c.header('Set-Cookie', 'b=2', { append: true }); + return c.text('ok'); + }); + + const response = await sendRequest(app, defaultEvent, defaultContext); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual('ok'); + expect(response.multiValueHeaders?.['set-cookie']).toEqual(['a=1', 'b=2']); + }); + + // 12b. Single Set-Cookie header (different code path: string vs array) + it('should handle single set-cookie header', async () => { + app.get('/api/test', (c) => { + c.header('Set-Cookie', 'session=abc123'); + return c.text('ok'); + }); + + const response = await sendRequest(app, defaultEvent, defaultContext); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual('ok'); + expect(response.headers['set-cookie']).toBe('session=abc123'); + }); + + // 13. Empty response body (204) + it('should return empty body with 204 status', async () => { + app.get('/api/test', (c) => { + c.status(204); + return c.body(null); + }); + + const response = await sendRequest(app, defaultEvent, defaultContext); + + expect(response.statusCode).toEqual(204); + expect(response.body).toEqual(''); + }); + + // 14. 404 unmatched route + it('should return 404 for unmatched routes', async () => { + app.get('/api/exists', (c) => c.text('exists')); + + const response = await sendRequest( + app, + { + ...defaultEvent, + path: '/api/nonexistent', + }, + defaultContext, + ); + + expect(response.statusCode).toEqual(404); + }); + + // 15. Error handling + it('should return 500 when handler throws', async () => { + app.get('/api/test', () => { + throw new Error('boom'); + }); + + const response = await sendRequest(app, defaultEvent, defaultContext); + + expect(response.statusCode).toEqual(500); + }); + + // 16. Method matching (GET vs PUT) + it('should match different HTTP methods', async () => { + app.get('/api', (c) => c.text('GET')); + app.put('/api', (c) => c.text('PUT')); + + const getResponse = await sendRequest( + app, + { + ...defaultEvent, + path: '/api', + httpMethod: 'GET', + }, + defaultContext, + ); + + expect(getResponse.statusCode).toEqual(200); + expect(getResponse.body).toEqual('GET'); + + const putResponse = await sendRequest( + app, + { + ...defaultEvent, + path: '/api', + httpMethod: 'PUT', + }, + defaultContext, + ); + + expect(putResponse.statusCode).toEqual(200); + expect(putResponse.body).toEqual('PUT'); + }); + + // 17. DELETE method + it('should handle DELETE method', async () => { + app.delete('/api', (c) => c.text('deleted')); + + const response = await sendRequest( + app, + { + ...defaultEvent, + path: '/api', + httpMethod: 'DELETE', + }, + defaultContext, + ); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual('deleted'); + }); + + // 18. PATCH method + it('should handle PATCH method with JSON body', async () => { + app.patch('/api', async (c) => { + const body = await c.req.json(); + return c.json(body); + }); + + const response = await sendRequest( + app, + { + ...defaultEvent, + path: '/api', + httpMethod: 'PATCH', + body: JSON.stringify({ updated: true }), + headers: { 'Content-Type': 'application/json' }, + }, + defaultContext, + ); + + expect(response.statusCode).toEqual(200); + expect(JSON.parse(response.body)).toEqual({ updated: true }); + }); + + // 19. Request headers pass-through + it('should pass request headers through to handler', async () => { + app.get('/api', (c) => c.text(c.req.header('authorization') || '')); + + const response = await sendRequest( + app, + { + ...defaultEvent, + path: '/api', + headers: { Authorization: 'Bearer test-token' }, + }, + defaultContext, + ); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual('Bearer test-token'); + }); + + // 20. isBase64Encoded flag propagation + it('should propagate isBase64Encoded flag to response', async () => { + app.get('/api/test', (c) => c.json({ ok: true })); + + const response = await sendRequest( + app, + { + ...defaultEvent, + isBase64Encoded: false, + }, + defaultContext, + ); + + expect(response.statusCode).toEqual(200); + expect(response.isBase64Encoded).toEqual(false); + }); +}); diff --git a/tests/index-tencent-hono.spec.test.ts b/tests/index-tencent-hono.spec.test.ts new file mode 100644 index 0000000..be695f9 --- /dev/null +++ b/tests/index-tencent-hono.spec.test.ts @@ -0,0 +1,121 @@ +import { Hono } from 'hono'; +import { createTencentContext, createTencentEvent } from './fixtures/tencentContext'; +import { sendRequest } from './fixtures/requestHelper'; + +describe('Tencent SCF with Hono', () => { + let app: Hono; + + beforeEach(() => { + app = new Hono(); + }); + + it('happy path: GET should return JSON response', async () => { + app.get('/*', (c) => c.json({ ok: true })); + + const response = await sendRequest( + app, + createTencentEvent({ httpMethod: 'GET' }) as unknown as Record, + createTencentContext() as unknown as Record, + ); + + expect(response.statusCode).toEqual(200); + expect(JSON.parse(response.body)).toEqual({ ok: true }); + }); + + it('JSON body: POST should echo body back', async () => { + app.post('/*', async (c) => { + const body = await c.req.json(); + return c.json(body); + }); + + const response = await sendRequest( + app, + createTencentEvent({ + httpMethod: 'POST', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' }, + }) as unknown as Record, + createTencentContext() as unknown as Record, + ); + + expect(response.statusCode).toEqual(200); + expect(JSON.parse(response.body)).toEqual({ hello: 'world' }); + }); + + it('Query params: GET should echo query parameters', async () => { + app.get('/*', (c) => c.json(c.req.query())); + + const response = await sendRequest( + app, + createTencentEvent({ + httpMethod: 'GET', + queryStringParameters: { foo: 'bar' }, + }) as unknown as Record, + createTencentContext() as unknown as Record, + ); + + expect(response.statusCode).toEqual(200); + expect(JSON.parse(response.body)).toEqual({ foo: 'bar' }); + }); + + it('Custom headers: GET should return custom header', async () => { + app.get('/*', (c) => { + c.header('X-Custom', 'value'); + return c.text('ok'); + }); + + const response = await sendRequest( + app, + createTencentEvent({ httpMethod: 'GET' }) as unknown as Record, + createTencentContext() as unknown as Record, + ); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual('ok'); + expect(response.headers['x-custom']).toEqual('value'); + }); + + it('404: unmatched route should return 404', async () => { + app.get('/api/exists', (c) => c.text('exists')); + + const response = await sendRequest( + app, + createTencentEvent({ + httpMethod: 'GET', + path: '/api/notexists', + }) as unknown as Record, + createTencentContext() as unknown as Record, + ); + + expect(response.statusCode).toEqual(404); + }); + + it('500 error: route that throws should return 500', async () => { + app.get('/*', () => { + throw new Error('Internal server error'); + }); + + const response = await sendRequest( + app, + createTencentEvent({ httpMethod: 'GET' }) as unknown as Record, + createTencentContext() as unknown as Record, + ); + + expect(response.statusCode).toEqual(500); + }); + + it('should return Tencent SCF response format', async () => { + app.get('/*', (c) => c.json({ provider: 'tencent' })); + + const response = await sendRequest( + app, + createTencentEvent() as unknown as Record, + createTencentContext() as unknown as Record, + ); + + expect(response).toHaveProperty('statusCode'); + expect(response).toHaveProperty('body'); + expect(response).toHaveProperty('headers'); + expect(response).toHaveProperty('isBase64Encoded'); + }); +}); diff --git a/tests/index-volcengine-hono.spec.test.ts b/tests/index-volcengine-hono.spec.test.ts new file mode 100644 index 0000000..384fc26 --- /dev/null +++ b/tests/index-volcengine-hono.spec.test.ts @@ -0,0 +1,120 @@ +import { Hono } from 'hono'; +import { createVolcengineContext, createVolcengineEvent } from './fixtures/volcengineContext'; +import { sendRequest } from './fixtures/requestHelper'; + +describe('Volcengine veFaaS with Hono', () => { + let app: Hono; + + beforeEach(() => { + app = new Hono(); + }); + + it('happy path: GET should return JSON response', async () => { + app.get('/*', (c) => c.json({ ok: true })); + + const response = await sendRequest( + app, + createVolcengineEvent({ method: 'GET' }) as unknown as Record, + createVolcengineContext() as unknown as Record, + ); + + expect(response.statusCode).toEqual(200); + expect(JSON.parse(response.body)).toEqual({ ok: true }); + }); + + it('JSON body: POST should echo body back', async () => { + app.post('/*', async (c) => { + const body = await c.req.json(); + return c.json(body); + }); + + const response = await sendRequest( + app, + createVolcengineEvent({ + method: 'POST', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' }, + }) as unknown as Record, + createVolcengineContext() as unknown as Record, + ); + + expect(response.statusCode).toEqual(200); + expect(JSON.parse(response.body)).toEqual({ hello: 'world' }); + }); + + it('Query params: GET should echo query parameters', async () => { + app.get('/*', (c) => c.json(c.req.query())); + + const response = await sendRequest( + app, + createVolcengineEvent({ + method: 'GET', + query: { foo: 'bar' }, + }) as unknown as Record, + createVolcengineContext() as unknown as Record, + ); + + expect(response.statusCode).toEqual(200); + expect(JSON.parse(response.body)).toEqual({ foo: 'bar' }); + }); + + it('Custom headers: GET should return custom header', async () => { + app.get('/*', (c) => { + c.header('X-Custom', 'value'); + return c.text('ok'); + }); + + const response = await sendRequest( + app, + createVolcengineEvent({ method: 'GET' }) as unknown as Record, + createVolcengineContext() as unknown as Record, + ); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual('ok'); + expect(response.headers['x-custom']).toEqual('value'); + }); + + it('404: unmatched route should return 404', async () => { + app.get('/api/exists', (c) => c.text('exists')); + + const response = await sendRequest( + app, + createVolcengineEvent({ + method: 'GET', + path: '/api/notexists', + }) as unknown as Record, + createVolcengineContext() as unknown as Record, + ); + + expect(response.statusCode).toEqual(404); + }); + + it('500 error: route that throws should return 500', async () => { + app.get('/*', () => { + throw new Error('Internal server error'); + }); + + const response = await sendRequest( + app, + createVolcengineEvent({ method: 'GET' }) as unknown as Record, + createVolcengineContext() as unknown as Record, + ); + + expect(response.statusCode).toEqual(500); + }); + + it('should return Volcengine veFaaS response format', async () => { + app.get('/*', (c) => c.json({ provider: 'volcengine' })); + + const response = await sendRequest( + app, + createVolcengineEvent() as unknown as Record, + createVolcengineContext() as unknown as Record, + ); + + expect(response).toHaveProperty('statusCode'); + expect(response).toHaveProperty('body'); + expect(response).toHaveProperty('headers'); + }); +}); diff --git a/tests/unit/framework.test.ts b/tests/unit/framework.test.ts index d13abb9..602c244 100644 --- a/tests/unit/framework.test.ts +++ b/tests/unit/framework.test.ts @@ -1,5 +1,8 @@ import { constructFramework } from '../../src/framework'; import Koa from 'koa2'; +import { Hono } from 'hono'; +import ServerlessResponse from '../../src/serverlessResponse'; +import ServerlessRequest from '../../src/serverlessRequest'; describe('framework', () => { describe('constructFramework', () => { @@ -25,4 +28,82 @@ describe('framework', () => { ); }); }); + + describe('Hono', () => { + it('should detect Hono app by fetch method', () => { + const app = new Hono(); + const result = constructFramework(app); + expect(typeof result).toBe('function'); + }); + + it('honoHandler should convert ServerlessRequest to Web API Request and back to ServerlessResponse', async () => { + const app = new Hono(); + app.get('/api/test', (c) => c.json({ ok: true })); + + const handler = constructFramework(app); + const request = new ServerlessRequest({ + method: 'GET', + url: '/api/test', + path: '/api/test', + headers: {}, + body: undefined, + remoteAddress: '', + isBase64Encoded: false, + }); + + const response = await handler(request); + + expect(response.statusCode).toBe(200); + expect(ServerlessResponse.body(response).toString()).toBe('{"ok":true}'); + }); + + it('should handle array-valued IncomingHttpHeaders in honoHandler', async () => { + const app = new Hono(); + app.get('/api/test', (c) => { + const val = c.req.header('x-array'); + return c.text(val || 'none'); + }); + + const handler = constructFramework(app); + const request = new ServerlessRequest({ + method: 'GET', + url: '/api/test', + path: '/api/test', + headers: { 'x-array': 'v1' } as { [key: string]: string | number }, + body: undefined, + remoteAddress: '', + isBase64Encoded: false, + }); + Object.assign(request, { headers: { 'x-array': ['v1', 'v2'] } }); + + const response = await handler(request); + + expect(response.statusCode).toBe(200); + // Multi-valued headers are joined with ', ' in the Request + expect(ServerlessResponse.body(response).toString()).toBe('v1, v2'); + }); + + it('should handle POST with body in honoHandler (hasBody = true)', async () => { + const app = new Hono(); + app.post('/api', async (c) => { + const body = await c.req.text(); + return c.text(body); + }); + + const handler = constructFramework(app); + const request = new ServerlessRequest({ + method: 'POST', + url: '/api', + path: '/api', + headers: { 'content-type': 'text/plain' }, + body: Buffer.from('hello'), + remoteAddress: '', + isBase64Encoded: false, + }); + + const response = await handler(request); + expect(response.statusCode).toBe(200); + expect(ServerlessResponse.body(response).toString()).toBe('hello'); + }); + }); }); diff --git a/tests/unit/honoTypes.test.ts b/tests/unit/honoTypes.test.ts new file mode 100644 index 0000000..f4325ac --- /dev/null +++ b/tests/unit/honoTypes.test.ts @@ -0,0 +1,11 @@ +import serverlessAdapter from '../../src/index'; +import { Hono } from 'hono'; + +describe('Hono type acceptance', () => { + it('should accept Hono app in serverlessAdapter type signature', () => { + const app = new Hono(); + // Type widened to accept HonoApp — verifies Hono is accepted + const handler = serverlessAdapter(app); + expect(typeof handler).toBe('function'); + }); +});