Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
63 changes: 62 additions & 1 deletion src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,70 @@ const callableFn = (callback: (req: any, res: any) => Promise<void>) => {
};
};

const honoHandler = (app: { fetch: (req: Request) => Response | Promise<Response> }) => {
return async (request: ServerlessRequest): Promise<ServerlessResponse> => {
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') {
Expand Down
11 changes: 9 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,12 +22,13 @@ type HandlerResult = {
body: string;
headers: IncomingHttpHeaders;
isBase64Encoded: boolean;
multiValueHeaders?: { [key: string]: string[] };
};

type Handler = (event: ProviderEvent, context: ProviderContext) => Promise<HandlerResult>;

const serverlessAdapter = (
app: Express | Application,
app: Express | Application | HonoApp,
options?: ServerlessAdapterOptions,
): Handler => {
const serverlessFramework = constructFramework(app);
Expand Down
3 changes: 3 additions & 0 deletions src/providers/aliyun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export class AliyunProvider extends BaseProvider {
body: response.body,
headers: response.headers,
isBase64Encoded: response.isBase64Encoded,
multiValueHeaders: (response as Record<string, unknown>).multiValueHeaders as
| { [key: string]: string[] }
| undefined,
};
}

Expand Down
3 changes: 3 additions & 0 deletions src/providers/tencent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export class TencentProvider extends BaseProvider {
statusCode: response.statusCode,
headers: response.headers,
body: response.body,
multiValueHeaders: (response as Record<string, unknown>).multiValueHeaders as
| { [key: string]: string[] }
| undefined,
};
}

Expand Down
3 changes: 3 additions & 0 deletions src/providers/volcengine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export class VolcengineProvider extends BaseProvider {
statusCode: response.statusCode,
headers: response.headers,
body: response.body,
multiValueHeaders: (response as Record<string, unknown>).multiValueHeaders as
| { [key: string]: string[] }
| undefined,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/types/aliyun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,5 @@ export interface AliyunResponse {
body: string;
headers: IncomingHttpHeaders;
isBase64Encoded: boolean;
multiValueHeaders?: { [key: string]: string[] };
}
9 changes: 8 additions & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> };

export type ServerlessEvent = {
path: string;
httpMethod: string;
Expand All @@ -57,6 +62,7 @@ export type ServerlessResponse = {
body: string;
headers: IncomingHttpHeaders;
isBase64Encoded: boolean;
multiValueHeaders?: { [key: string]: string[] };
};

/**
Expand All @@ -74,12 +80,13 @@ 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<{
statusCode: number;
body: string;
headers: IncomingHttpHeaders;
isBase64Encoded: boolean;
multiValueHeaders?: { [key: string]: string[] };
}>;
1 change: 1 addition & 0 deletions src/types/tencent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface TencentScfResponse {
statusCode: number;
headers: IncomingHttpHeaders;
body: string;
multiValueHeaders?: { [key: string]: string[] };
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/types/volcengine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface VolcengineVefaasResponse {
statusCode: number;
headers: IncomingHttpHeaders;
body: string;
multiValueHeaders?: { [key: string]: string[] };
}

/**
Expand Down
Loading
Loading