Skip to content

Commit 7cf4415

Browse files
lukeggchapmanLuke ChapmanGabrola
authored
fix(serverless): make lambda handler compatible with node runtime (#681)
Co-authored-by: Luke Chapman <luke.chapman@fireant.com.au> Co-authored-by: Youssef Gaber <1728215+Gabrola@users.noreply.github.com>
1 parent d9111cc commit 7cf4415

5 files changed

Lines changed: 223 additions & 35 deletions

File tree

.changeset/orange-frogs-unite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/serverless': patch
3+
---
4+
5+
Fix AWS Lambda handler not working in Node.js environments.

libs/ts-rest/serverless/src/lib/handlers/ts-rest-lambda.spec.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
15
import { initContract } from '@ts-rest/core';
26
import type {
37
APIGatewayProxyEvent,
@@ -158,7 +162,10 @@ describe('tsRestLambda', () => {
158162
},
159163
};
160164
},
161-
ping: async ({ body, params }) => {
165+
ping: async ({ body, params }, { responseHeaders }) => {
166+
responseHeaders.append('Cache-Control', 'no-cache');
167+
responseHeaders.append('Cache-Control', 'no-store');
168+
162169
return {
163170
status: 200,
164171
body: {
@@ -336,12 +343,14 @@ describe('tsRestLambda', () => {
336343
headers: {
337344
'access-control-allow-credentials': 'true',
338345
'access-control-allow-origin': 'http://localhost',
346+
'cache-control': 'no-cache, no-store',
339347
'content-type': 'application/json',
340348
vary: 'Origin',
341349
},
342350
multiValueHeaders: {
343351
'access-control-allow-credentials': ['true'],
344352
'access-control-allow-origin': ['http://localhost'],
353+
'cache-control': ['no-cache', 'no-store'],
345354
'content-type': ['application/json'],
346355
vary: ['Origin'],
347356
},
@@ -370,6 +379,7 @@ describe('tsRestLambda', () => {
370379
headers: {
371380
'access-control-allow-credentials': 'true',
372381
'access-control-allow-origin': 'http://localhost',
382+
'cache-control': 'no-cache, no-store',
373383
'content-type': 'application/json',
374384
vary: 'Origin',
375385
},
@@ -716,7 +726,43 @@ describe('tsRestLambda', () => {
716726
});
717727
});
718728

719-
it('should handle cookies returned in response', async () => {
729+
it('V1 should handle cookies returned in response', async () => {
730+
const event = createV1LambdaRequest({
731+
httpMethod: 'GET',
732+
path: '/test',
733+
queryStringParameters: {
734+
foo: 'baz',
735+
setCookies: 'true',
736+
},
737+
});
738+
739+
const response = await lambdaHandler(event as any, {} as any);
740+
expect(response).toEqual({
741+
statusCode: 200,
742+
headers: {
743+
'access-control-allow-credentials': 'true',
744+
'access-control-allow-origin': 'http://localhost',
745+
'content-type': 'application/json',
746+
'set-cookie':
747+
'foo=bar; path=/; expires=Thu, 21 Oct 2021 07:28:00 GMT; secure; httponly; samesite=strict, bar=foo; path=/; expires=Thu, 21 Oct 2021 07:28:00 GMT; secure; httponly; samesite=strict',
748+
vary: 'Origin',
749+
},
750+
multiValueHeaders: {
751+
'access-control-allow-credentials': ['true'],
752+
'access-control-allow-origin': ['http://localhost'],
753+
'content-type': ['application/json'],
754+
'set-cookie': [
755+
'foo=bar; path=/; expires=Thu, 21 Oct 2021 07:28:00 GMT; secure; httponly; samesite=strict',
756+
'bar=foo; path=/; expires=Thu, 21 Oct 2021 07:28:00 GMT; secure; httponly; samesite=strict',
757+
],
758+
vary: ['Origin'],
759+
},
760+
body: '{"foo":"baz"}',
761+
isBase64Encoded: false,
762+
});
763+
});
764+
765+
it('V2 should handle cookies returned in response', async () => {
720766
const event = createV2LambdaRequest({
721767
requestContext: {
722768
http: {

libs/ts-rest/serverless/src/lib/mappers/aws/api-gateway.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,16 @@ export async function responseToResult(
128128
const multiValueHeaders = {} as Record<string, string[]>;
129129

130130
response.headers.forEach((value, key) => {
131-
headers[key] = value;
131+
headers[key] = headers[key] ? `${headers[key]}, ${value}` : value;
132132

133-
if (key === 'set-cookie') {
134-
multiValueHeaders[key] = splitCookiesString(value);
135-
} else {
136-
multiValueHeaders[key] = value.split(',').map((v) => v.trim());
137-
}
133+
const multiValueHeaderValue =
134+
key === 'set-cookie'
135+
? splitCookiesString(value)
136+
: value.split(',').map((v) => v.trim());
137+
138+
multiValueHeaders[key] = multiValueHeaders[key]
139+
? [...multiValueHeaders[key], ...multiValueHeaderValue]
140+
: multiValueHeaderValue;
138141
});
139142

140143
let cookies = [] as string[];
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {
2+
arrayBufferToBase64,
3+
arrayBufferToString,
4+
blobToArrayBuffer,
5+
splitCookiesString,
6+
} from './utils';
7+
8+
describe('utils', async () => {
9+
const nullArrayBuffer = await blobToArrayBuffer(
10+
new Blob([Buffer.from('AAAAAAAAAAAAAA==', 'base64')]),
11+
);
12+
13+
describe('arrayBufferToBase64', () => {
14+
it('should convert ArrayBuffer with binary data to base64 string', async () => {
15+
const base64 = await arrayBufferToBase64(nullArrayBuffer);
16+
expect(base64).toBe('AAAAAAAAAAAAAA==');
17+
});
18+
19+
it('should convert ArrayBuffer with text data to base64 string', async () => {
20+
const buffer = new TextEncoder().encode('Hello World').buffer;
21+
const base64 = await arrayBufferToBase64(buffer);
22+
expect(base64).toBe(btoa('Hello World'));
23+
});
24+
25+
it('should convert Blob with binary data to base64 string', async () => {
26+
const blob = new Blob([nullArrayBuffer]);
27+
const base64 = await arrayBufferToBase64(blob);
28+
expect(base64).toBe('AAAAAAAAAAAAAA==');
29+
});
30+
31+
it('should convert Blob with text data to base64 string', async () => {
32+
const blob = new Blob(['Hello World']);
33+
const base64 = await arrayBufferToBase64(blob);
34+
expect(base64).toBe(btoa('Hello World'));
35+
});
36+
});
37+
38+
describe('arrayBufferToString', () => {
39+
it('should convert ArrayBuffer to string', async () => {
40+
const buffer = new TextEncoder().encode('Hello World').buffer;
41+
const string = await arrayBufferToString(buffer);
42+
expect(string).toBe('Hello World');
43+
});
44+
45+
it('should convert Blob to string', async () => {
46+
const blob = new Blob(['Hello World']);
47+
const string = await arrayBufferToString(blob);
48+
expect(string).toBe('Hello World');
49+
});
50+
51+
it('should convert ArrayBuffer with binary data to string', async () => {
52+
const string = await arrayBufferToString(nullArrayBuffer);
53+
expect(string).toBe('\0\0\0\0\0\0\0\0\0\0');
54+
});
55+
});
56+
57+
describe('splitCookiesString', () => {
58+
it('should split single cookie string properly', () => {
59+
const cookiesString = 'name=value';
60+
const result = splitCookiesString(cookiesString);
61+
expect(result).toEqual(['name=value']);
62+
});
63+
64+
it('should split multiple cookies string properly', () => {
65+
const cookiesString = 'name1=value1, name2=value2';
66+
const result = splitCookiesString(cookiesString);
67+
expect(result).toEqual(['name1=value1', 'name2=value2']);
68+
});
69+
70+
it('should handle cookies with semicolons in values properly', () => {
71+
const cookiesString = 'name1=value1;Path=/,name2=value2;Path=/another';
72+
const result = splitCookiesString(cookiesString);
73+
expect(result).toEqual([
74+
'name1=value1;Path=/',
75+
'name2=value2;Path=/another',
76+
]);
77+
});
78+
79+
it('should handle cookies with spaces around commas properly', () => {
80+
const cookiesString = 'name1=value1; Path=/, name2=value2; Path=/another';
81+
const result = splitCookiesString(cookiesString);
82+
expect(result).toEqual([
83+
'name1=value1; Path=/',
84+
'name2=value2; Path=/another',
85+
]);
86+
});
87+
});
88+
});

libs/ts-rest/serverless/src/lib/utils.ts

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,83 @@
1+
function isArrayBuffer(maybeBuffer: unknown): maybeBuffer is ArrayBuffer {
2+
return (
3+
maybeBuffer instanceof ArrayBuffer ||
4+
(typeof maybeBuffer === 'object' &&
5+
Object.prototype.toString.call(maybeBuffer) === '[object ArrayBuffer]')
6+
);
7+
}
8+
19
export async function arrayBufferToBase64(bufferOrBlob: ArrayBuffer | Blob) {
2-
const blob =
3-
bufferOrBlob instanceof Blob ? bufferOrBlob : new Blob([bufferOrBlob]);
4-
return await new Promise<string>((resolve) => {
5-
const reader = new FileReader();
6-
reader.onload = () => {
7-
const dataUrl = reader.result as string;
8-
const base64 = dataUrl.substring(dataUrl.indexOf(',') + 1);
9-
resolve(base64);
10-
};
11-
reader.readAsDataURL(blob);
12-
});
10+
if (isArrayBuffer(bufferOrBlob)) {
11+
if (globalThis.Buffer) {
12+
return Buffer.from(bufferOrBlob).toString('base64');
13+
}
14+
15+
return btoa(String.fromCharCode(...new Uint8Array(bufferOrBlob)));
16+
}
17+
18+
return arrayBufferToBase64(await blobToArrayBuffer(bufferOrBlob));
1319
}
1420

1521
export async function arrayBufferToString(bufferOrBlob: ArrayBuffer | Blob) {
16-
const blob =
17-
bufferOrBlob instanceof Blob ? bufferOrBlob : new Blob([bufferOrBlob]);
18-
return await new Promise<string>((resolve) => {
19-
const reader = new FileReader();
20-
reader.onload = () => {
21-
resolve(reader.result as string);
22-
};
23-
reader.readAsText(blob);
24-
});
22+
if (isArrayBuffer(bufferOrBlob)) {
23+
if (globalThis.TextDecoder) {
24+
return new TextDecoder().decode(bufferOrBlob);
25+
}
26+
27+
if (globalThis.Buffer) {
28+
return Buffer.from(bufferOrBlob).toString();
29+
}
30+
31+
return String.fromCharCode(...new Uint8Array(bufferOrBlob));
32+
}
33+
34+
if (bufferOrBlob instanceof Blob) {
35+
if (typeof bufferOrBlob.text === 'function') {
36+
return bufferOrBlob.text();
37+
}
38+
39+
if (globalThis.FileReader) {
40+
return await new Promise<string>((resolve) => {
41+
const reader = new FileReader();
42+
reader.onload = () => {
43+
resolve(reader.result as string);
44+
};
45+
reader.readAsText(bufferOrBlob);
46+
});
47+
}
48+
}
49+
50+
return arrayBufferToString(await blobToArrayBuffer(bufferOrBlob));
2551
}
2652

2753
export async function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
28-
return new Promise((resolve) => {
29-
const fileReader = new FileReader();
30-
fileReader.onload = () => {
31-
resolve(fileReader.result as ArrayBuffer);
32-
};
33-
fileReader.readAsArrayBuffer(blob);
34-
});
54+
if (typeof blob.arrayBuffer === 'function') {
55+
return await blob.arrayBuffer();
56+
}
57+
58+
for (const symbolKey of Object.getOwnPropertySymbols(blob)) {
59+
// detecting if blob is a jsdom polyfill
60+
if (symbolKey.description === 'impl') {
61+
const blobImpl = (blob as any)[symbolKey];
62+
const buffer = blobImpl._buffer as Buffer;
63+
return buffer.buffer.slice(
64+
buffer.byteOffset,
65+
buffer.byteOffset + buffer.byteLength,
66+
);
67+
}
68+
}
69+
70+
if (globalThis.FileReader) {
71+
return new Promise((resolve) => {
72+
const fileReader = new FileReader();
73+
fileReader.onload = () => {
74+
resolve(fileReader.result as ArrayBuffer);
75+
};
76+
fileReader.readAsArrayBuffer(blob);
77+
});
78+
}
79+
80+
throw new Error('Unable to convert blob to array buffer');
3581
}
3682

3783
// Credits: https://github.com/nfriedly/set-cookie-parser/blob/master/lib/set-cookie.js

0 commit comments

Comments
 (0)