Skip to content

Commit

Permalink
Merge pull request #1 from geek-fun/feat/enable-koa-support
Browse files Browse the repository at this point in the history
feat: setup test for koa application
  • Loading branch information
Blankll authored Dec 22, 2024
2 parents a126152 + bf80d98 commit 4ef1a81
Show file tree
Hide file tree
Showing 12 changed files with 1,470 additions and 54 deletions.
1,171 changes: 1,170 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,13 @@
},
"devDependencies": {
"@eslint/js": "^9.12.0",
"@koa/router": "^13.1.0",
"@types/debug": "^4.1.12",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.13",
"@types/koa": "^2.15.0",
"@types/koa-static": "^4.0.4",
"@types/koa__router": "^12.0.4",
"@types/node": "^22.7.4",
"@typescript-eslint/eslint-plugin": "^8.8.0",
"@typescript-eslint/parser": "^8.8.0",
Expand All @@ -65,6 +69,9 @@
"globals": "^15.10.0",
"husky": "^9.1.6",
"jest": "^29.7.0",
"koa": "^2.15.3",
"koa-body": "^6.0.1",
"koa-static": "^5.0.0",
"prettier": "^3.3.3",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
Expand Down
48 changes: 40 additions & 8 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Context, Event } from './types';
import ServerlessRequest from './serverlessRequest';
import url from 'node:url';
import ServerlessResponse from './serverlessResponse';
import { debug } from './common';

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

const requestBody = (event: Event) => {
if (event.body === undefined || event.body === null) {
return undefined;
}
const type = typeof event.body;

if (Buffer.isBuffer(event.body)) {
return event.body;
} else if (type === 'string') {
return Buffer.from(event.body as string, event.isBase64Encoded ? 'base64' : 'utf8');
} else if (type === 'object') {
return Buffer.from(JSON.stringify(event.body));
}

throw new Error(`Unexpected event.body type: ${typeof event.body}`);
};

const requestHeaders = (event: Event) => {
const initialHeader = {} as Record<string, string>;

// if (event.multiValueHeaders) {
// Object.keys(event.multiValueHeaders).reduce((headers, key) => {
// headers[key.toLowerCase()] = event.multiValueHeaders[key].join(', ');
// return headers;
// }, initialHeader);
// }

return Object.keys(event.headers).reduce((headers, key) => {
headers[key.toLowerCase()] = event.headers[key];
return headers;
}, initialHeader);
};

export const constructFrameworkContext = (event: Event, context: Context) => {
debug(`constructFrameworkContext: ${JSON.stringify({ event, context })}`);
const body = requestBody(event);
const headers = requestHeaders(event);

const request = new ServerlessRequest({
method: event.httpMethod,
headers: event.headers,
body:
event.body !== undefined && event.body !== null
? Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8')
: undefined,
path: event.path,
headers,
body,
remoteAddress: '',
url: url.format({
pathname: event.path,
query: event.queryParameters,
}),
isBase64Encoded: event.isBase64Encoded,
});
const response = new ServerlessResponse(request);

return { request, response };
return { request };
};
25 changes: 25 additions & 0 deletions src/framework.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Express } from 'express';
import Application from 'koa';
import ServerlessResponse from './serverlessResponse';
import ServerlessRequest from './serverlessRequest';

// eslint-disable-next-line
const callableFn = (callback: (req: any, res: any) => Promise<void>) => {
return async (request: ServerlessRequest) => {
const response = new ServerlessResponse(request);

callback(request, response);

return response;
};
};

export const constructFramework = (app: Express | Application) => {
if (app instanceof Application) {
return callableFn(app.callback());
} else if (typeof app === 'function') {
return callableFn(app);
} else {
throw new Error(`Unsupported framework ${app}`);
}
};
12 changes: 6 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { Express } from 'express';
import Application from 'koa';
import { ServerlessAdapter } from './types';
import sendRequest from './sendRequest';
import { IncomingHttpHeaders } from 'http';
import { constructFrameworkContext } from './context';
import { buildResponse, waitForStreamComplete } from './transport';
import { constructFramework } from './framework';

const serverlessAdapter: ServerlessAdapter = (app: Express) => {
const serverlessAdapter: ServerlessAdapter = (app: Express | Application) => {
const serverlessFramework = constructFramework(app);
return async (event, context) => {
const { request, response } = constructFrameworkContext(event, context);
const { request } = constructFrameworkContext(event, context);

try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
await sendRequest(app, request, response);
const response = await serverlessFramework(request);
await waitForStreamComplete(response);
return buildResponse({ request, response });
} catch (err) {
Expand Down
8 changes: 0 additions & 8 deletions src/sendRequest.ts

This file was deleted.

29 changes: 12 additions & 17 deletions src/serverlessRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const HTTPS_PORT = 443;
interface ServerlessRequestOptions {
method: string;
url: string;
path: string;
headers: { [key: string]: string | number };
body: Buffer | string | undefined;
remoteAddress: string;
Expand All @@ -21,46 +22,40 @@ export default class ServerlessRequest extends IncomingMessage {

isBase64Encoded: boolean;

constructor({
method,
url,
headers,
body,
remoteAddress,
isBase64Encoded,
}: ServerlessRequestOptions) {
constructor(request: ServerlessRequestOptions) {
super({
encrypted: true,
readable: false,
remoteAddress,
remoteAddress: request.remoteAddress,
address: () => ({ port: HTTPS_PORT }),
end: NO_OP,
destroy: NO_OP,
path: request.path,
headers: request.headers,
} as unknown as Socket);

const combinedHeaders = Object.fromEntries(
Object.entries({
...headers,
'content-length': Buffer.byteLength(body ?? '').toString(),
...request.headers,
'content-length': Buffer.byteLength(request.body ?? '').toString(),
}).map(([key, value]) => [key.toLowerCase(), value]),
);

Object.assign(this, {
...request,
complete: true,
httpVersion: '1.1',
httpVersionMajor: '1',
httpVersionMinor: '1',
method,
url,
headers: combinedHeaders,
});

this.body = body;
this.ip = remoteAddress;
this.isBase64Encoded = isBase64Encoded;
this.body = request.body;
this.ip = request.remoteAddress;
this.isBase64Encoded = request.isBase64Encoded;

this._read = () => {
this.push(body);
this.push(request.body);
this.push(null);
};
}
Expand Down
9 changes: 2 additions & 7 deletions src/serverlessResponse.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { IncomingHttpHeaders, ServerResponse } from 'http';
import { IncomingMessage } from 'node:http';
import ServerlessRequest from './serverlessRequest';
import { Socket } from 'node:net';
import { debug } from './common';
import ServerlessRequest from './serverlessRequest';

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

Expand Down Expand Up @@ -37,12 +36,8 @@ export default class ServerlessResponse extends ServerResponse {
[BODY]: Buffer[];
[HEADERS]: IncomingHttpHeaders;

static from(res: IncomingMessage): ServerlessResponse {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
static from(res: ServerlessRequest): ServerlessResponse {
const response = new ServerlessResponse(res);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const { statusCode = 0, headers, body } = res;
response.statusCode = statusCode;
response[HEADERS] = headers;
Expand Down
57 changes: 51 additions & 6 deletions src/transport.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { Writable } from 'stream';
import { IncomingHttpHeaders } from 'http';
import ServerlessRequest from './serverlessRequest';
import ServerlessResponse from './serverlessResponse';

type MultiValueHeaders = {
[key: string]: string[];
};
export const waitForStreamComplete = (stream: Writable): Promise<Writable> => {
if (stream.writableFinished || stream.writableEnded) {
return Promise.resolve(stream);
}

return new Promise((resolve, reject) => {
stream.once('error', complete);
stream.once('end', complete);
stream.once('finish', complete);

let isComplete = false;

function complete(err?: Error) {
Expand All @@ -31,20 +31,65 @@ export const waitForStreamComplete = (stream: Writable): Promise<Writable> => {
resolve(stream);
}
}

stream.once('error', complete);
stream.once('end', complete);
stream.once('finish', complete);
});
};

const sanitizeHeaders = (headers: IncomingHttpHeaders) => {
return Object.keys(headers).reduce(
(memo, key) => {
const value = headers[key];

if (Array.isArray(value)) {
memo.multiValueHeaders[key] = value;
if (key.toLowerCase() !== 'set-cookie') {
memo.headers[key] = value.join(', ');
}
} else {
memo.headers[key] = value == null ? '' : value.toString();
}

return memo;
},
{
headers: {} as IncomingHttpHeaders,
multiValueHeaders: {} as MultiValueHeaders,
},
);
};

export const buildResponse = ({
request,
response,
}: {
request: ServerlessRequest;
response: ServerlessResponse;
}) => {
const { headers, multiValueHeaders } = sanitizeHeaders(ServerlessResponse.headers(response));

let body = ServerlessResponse.body(response).toString(
request.isBase64Encoded ? 'base64' : 'utf8',
);
if (headers['transfer-encoding'] === 'chunked' || response.chunkedEncoding) {
const raw = ServerlessResponse.body(response).toString().split('\r\n');
const parsed = [];
for (let i = 0; i < raw.length; i += 2) {
const size = parseInt(raw[i], 16);
const value = raw[i + 1];
if (value) {
parsed.push(value.substring(0, size));
}
}
body = parsed.join('');
}
return {
statusCode: response.statusCode,
body: ServerlessResponse.body(response).toString(request.isBase64Encoded ? 'base64' : 'utf8'),
headers: response.headers,
body,
headers,
multiValueHeaders,
isBase64Encoded: request.isBase64Encoded,
};
};
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Express } from 'express';
import Application from 'koa';
import { IncomingHttpHeaders } from 'http';

type AliyunApiGatewayEvent = {
Expand Down Expand Up @@ -52,7 +53,7 @@ type AliyunApiGatewayContext = {
export type Event = AliyunApiGatewayEvent;
export type Context = AliyunApiGatewayContext;

export type ServerlessAdapter = (app: Express) => (
export type ServerlessAdapter = (app: Express | Application) => (
event: Event,
context: Context,
) => Promise<{
Expand Down
File renamed without changes.
Loading

0 comments on commit 4ef1a81

Please sign in to comment.