Skip to content

Commit 4ef1a81

Browse files
authored
Merge pull request #1 from geek-fun/feat/enable-koa-support
feat: setup test for koa application
2 parents a126152 + bf80d98 commit 4ef1a81

12 files changed

+1470
-54
lines changed

package-lock.json

Lines changed: 1170 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,13 @@
5151
},
5252
"devDependencies": {
5353
"@eslint/js": "^9.12.0",
54+
"@koa/router": "^13.1.0",
5455
"@types/debug": "^4.1.12",
5556
"@types/express": "^5.0.0",
5657
"@types/jest": "^29.5.13",
58+
"@types/koa": "^2.15.0",
59+
"@types/koa-static": "^4.0.4",
60+
"@types/koa__router": "^12.0.4",
5761
"@types/node": "^22.7.4",
5862
"@typescript-eslint/eslint-plugin": "^8.8.0",
5963
"@typescript-eslint/parser": "^8.8.0",
@@ -65,6 +69,9 @@
6569
"globals": "^15.10.0",
6670
"husky": "^9.1.6",
6771
"jest": "^29.7.0",
72+
"koa": "^2.15.3",
73+
"koa-body": "^6.0.1",
74+
"koa-static": "^5.0.0",
6875
"prettier": "^3.3.3",
6976
"ts-jest": "^29.2.5",
7077
"ts-node": "^10.9.2",

src/context.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Context, Event } from './types';
22
import ServerlessRequest from './serverlessRequest';
33
import url from 'node:url';
4-
import ServerlessResponse from './serverlessResponse';
54
import { debug } from './common';
65

76
// const requestRemoteAddress = (event) => {
@@ -11,23 +10,56 @@ import { debug } from './common';
1110
// return event.requestContext.identity.sourceIp;
1211
// };
1312

13+
const requestBody = (event: Event) => {
14+
if (event.body === undefined || event.body === null) {
15+
return undefined;
16+
}
17+
const type = typeof event.body;
18+
19+
if (Buffer.isBuffer(event.body)) {
20+
return event.body;
21+
} else if (type === 'string') {
22+
return Buffer.from(event.body as string, event.isBase64Encoded ? 'base64' : 'utf8');
23+
} else if (type === 'object') {
24+
return Buffer.from(JSON.stringify(event.body));
25+
}
26+
27+
throw new Error(`Unexpected event.body type: ${typeof event.body}`);
28+
};
29+
30+
const requestHeaders = (event: Event) => {
31+
const initialHeader = {} as Record<string, string>;
32+
33+
// if (event.multiValueHeaders) {
34+
// Object.keys(event.multiValueHeaders).reduce((headers, key) => {
35+
// headers[key.toLowerCase()] = event.multiValueHeaders[key].join(', ');
36+
// return headers;
37+
// }, initialHeader);
38+
// }
39+
40+
return Object.keys(event.headers).reduce((headers, key) => {
41+
headers[key.toLowerCase()] = event.headers[key];
42+
return headers;
43+
}, initialHeader);
44+
};
45+
1446
export const constructFrameworkContext = (event: Event, context: Context) => {
1547
debug(`constructFrameworkContext: ${JSON.stringify({ event, context })}`);
48+
const body = requestBody(event);
49+
const headers = requestHeaders(event);
50+
1651
const request = new ServerlessRequest({
1752
method: event.httpMethod,
18-
headers: event.headers,
19-
body:
20-
event.body !== undefined && event.body !== null
21-
? Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8')
22-
: undefined,
53+
path: event.path,
54+
headers,
55+
body,
2356
remoteAddress: '',
2457
url: url.format({
2558
pathname: event.path,
2659
query: event.queryParameters,
2760
}),
2861
isBase64Encoded: event.isBase64Encoded,
2962
});
30-
const response = new ServerlessResponse(request);
3163

32-
return { request, response };
64+
return { request };
3365
};

src/framework.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Express } from 'express';
2+
import Application from 'koa';
3+
import ServerlessResponse from './serverlessResponse';
4+
import ServerlessRequest from './serverlessRequest';
5+
6+
// eslint-disable-next-line
7+
const callableFn = (callback: (req: any, res: any) => Promise<void>) => {
8+
return async (request: ServerlessRequest) => {
9+
const response = new ServerlessResponse(request);
10+
11+
callback(request, response);
12+
13+
return response;
14+
};
15+
};
16+
17+
export const constructFramework = (app: Express | Application) => {
18+
if (app instanceof Application) {
19+
return callableFn(app.callback());
20+
} else if (typeof app === 'function') {
21+
return callableFn(app);
22+
} else {
23+
throw new Error(`Unsupported framework ${app}`);
24+
}
25+
};

src/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import { Express } from 'express';
2+
import Application from 'koa';
23
import { ServerlessAdapter } from './types';
3-
import sendRequest from './sendRequest';
44
import { IncomingHttpHeaders } from 'http';
55
import { constructFrameworkContext } from './context';
66
import { buildResponse, waitForStreamComplete } from './transport';
7+
import { constructFramework } from './framework';
78

8-
const serverlessAdapter: ServerlessAdapter = (app: Express) => {
9+
const serverlessAdapter: ServerlessAdapter = (app: Express | Application) => {
10+
const serverlessFramework = constructFramework(app);
911
return async (event, context) => {
10-
const { request, response } = constructFrameworkContext(event, context);
12+
const { request } = constructFrameworkContext(event, context);
1113

1214
try {
13-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
14-
// @ts-expect-error
15-
await sendRequest(app, request, response);
15+
const response = await serverlessFramework(request);
1616
await waitForStreamComplete(response);
1717
return buildResponse({ request, response });
1818
} catch (err) {

src/sendRequest.ts

Lines changed: 0 additions & 8 deletions
This file was deleted.

src/serverlessRequest.ts

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const HTTPS_PORT = 443;
66
interface ServerlessRequestOptions {
77
method: string;
88
url: string;
9+
path: string;
910
headers: { [key: string]: string | number };
1011
body: Buffer | string | undefined;
1112
remoteAddress: string;
@@ -21,46 +22,40 @@ export default class ServerlessRequest extends IncomingMessage {
2122

2223
isBase64Encoded: boolean;
2324

24-
constructor({
25-
method,
26-
url,
27-
headers,
28-
body,
29-
remoteAddress,
30-
isBase64Encoded,
31-
}: ServerlessRequestOptions) {
25+
constructor(request: ServerlessRequestOptions) {
3226
super({
3327
encrypted: true,
3428
readable: false,
35-
remoteAddress,
29+
remoteAddress: request.remoteAddress,
3630
address: () => ({ port: HTTPS_PORT }),
3731
end: NO_OP,
3832
destroy: NO_OP,
33+
path: request.path,
34+
headers: request.headers,
3935
} as unknown as Socket);
4036

4137
const combinedHeaders = Object.fromEntries(
4238
Object.entries({
43-
...headers,
44-
'content-length': Buffer.byteLength(body ?? '').toString(),
39+
...request.headers,
40+
'content-length': Buffer.byteLength(request.body ?? '').toString(),
4541
}).map(([key, value]) => [key.toLowerCase(), value]),
4642
);
4743

4844
Object.assign(this, {
45+
...request,
4946
complete: true,
5047
httpVersion: '1.1',
5148
httpVersionMajor: '1',
5249
httpVersionMinor: '1',
53-
method,
54-
url,
5550
headers: combinedHeaders,
5651
});
5752

58-
this.body = body;
59-
this.ip = remoteAddress;
60-
this.isBase64Encoded = isBase64Encoded;
53+
this.body = request.body;
54+
this.ip = request.remoteAddress;
55+
this.isBase64Encoded = request.isBase64Encoded;
6156

6257
this._read = () => {
63-
this.push(body);
58+
this.push(request.body);
6459
this.push(null);
6560
};
6661
}

src/serverlessResponse.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { IncomingHttpHeaders, ServerResponse } from 'http';
2-
import { IncomingMessage } from 'node:http';
3-
import ServerlessRequest from './serverlessRequest';
42
import { Socket } from 'node:net';
53
import { debug } from './common';
4+
import ServerlessRequest from './serverlessRequest';
65

76
const headerEnd = '\r\n\r\n';
87

@@ -37,12 +36,8 @@ export default class ServerlessResponse extends ServerResponse {
3736
[BODY]: Buffer[];
3837
[HEADERS]: IncomingHttpHeaders;
3938

40-
static from(res: IncomingMessage): ServerlessResponse {
41-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
42-
// @ts-expect-error
39+
static from(res: ServerlessRequest): ServerlessResponse {
4340
const response = new ServerlessResponse(res);
44-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
45-
// @ts-expect-error
4641
const { statusCode = 0, headers, body } = res;
4742
response.statusCode = statusCode;
4843
response[HEADERS] = headers;

src/transport.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { Writable } from 'stream';
2+
import { IncomingHttpHeaders } from 'http';
23
import ServerlessRequest from './serverlessRequest';
34
import ServerlessResponse from './serverlessResponse';
45

6+
type MultiValueHeaders = {
7+
[key: string]: string[];
8+
};
59
export const waitForStreamComplete = (stream: Writable): Promise<Writable> => {
610
if (stream.writableFinished || stream.writableEnded) {
711
return Promise.resolve(stream);
812
}
913

1014
return new Promise((resolve, reject) => {
11-
stream.once('error', complete);
12-
stream.once('end', complete);
13-
stream.once('finish', complete);
14-
1515
let isComplete = false;
1616

1717
function complete(err?: Error) {
@@ -31,20 +31,65 @@ export const waitForStreamComplete = (stream: Writable): Promise<Writable> => {
3131
resolve(stream);
3232
}
3333
}
34+
35+
stream.once('error', complete);
36+
stream.once('end', complete);
37+
stream.once('finish', complete);
3438
});
3539
};
3640

41+
const sanitizeHeaders = (headers: IncomingHttpHeaders) => {
42+
return Object.keys(headers).reduce(
43+
(memo, key) => {
44+
const value = headers[key];
45+
46+
if (Array.isArray(value)) {
47+
memo.multiValueHeaders[key] = value;
48+
if (key.toLowerCase() !== 'set-cookie') {
49+
memo.headers[key] = value.join(', ');
50+
}
51+
} else {
52+
memo.headers[key] = value == null ? '' : value.toString();
53+
}
54+
55+
return memo;
56+
},
57+
{
58+
headers: {} as IncomingHttpHeaders,
59+
multiValueHeaders: {} as MultiValueHeaders,
60+
},
61+
);
62+
};
63+
3764
export const buildResponse = ({
3865
request,
3966
response,
4067
}: {
4168
request: ServerlessRequest;
4269
response: ServerlessResponse;
4370
}) => {
71+
const { headers, multiValueHeaders } = sanitizeHeaders(ServerlessResponse.headers(response));
72+
73+
let body = ServerlessResponse.body(response).toString(
74+
request.isBase64Encoded ? 'base64' : 'utf8',
75+
);
76+
if (headers['transfer-encoding'] === 'chunked' || response.chunkedEncoding) {
77+
const raw = ServerlessResponse.body(response).toString().split('\r\n');
78+
const parsed = [];
79+
for (let i = 0; i < raw.length; i += 2) {
80+
const size = parseInt(raw[i], 16);
81+
const value = raw[i + 1];
82+
if (value) {
83+
parsed.push(value.substring(0, size));
84+
}
85+
}
86+
body = parsed.join('');
87+
}
4488
return {
4589
statusCode: response.statusCode,
46-
body: ServerlessResponse.body(response).toString(request.isBase64Encoded ? 'base64' : 'utf8'),
47-
headers: response.headers,
90+
body,
91+
headers,
92+
multiValueHeaders,
4893
isBase64Encoded: request.isBase64Encoded,
4994
};
5095
};

src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Express } from 'express';
2+
import Application from 'koa';
23
import { IncomingHttpHeaders } from 'http';
34

45
type AliyunApiGatewayEvent = {
@@ -52,7 +53,7 @@ type AliyunApiGatewayContext = {
5253
export type Event = AliyunApiGatewayEvent;
5354
export type Context = AliyunApiGatewayContext;
5455

55-
export type ServerlessAdapter = (app: Express) => (
56+
export type ServerlessAdapter = (app: Express | Application) => (
5657
event: Event,
5758
context: Context,
5859
) => Promise<{
File renamed without changes.

0 commit comments

Comments
 (0)