Skip to content

Commit

Permalink
feat: support < Node 18 using HTTPs and extend S3 support (#20)
Browse files Browse the repository at this point in the history
  • Loading branch information
sam committed Jan 30, 2023
1 parent 0023a25 commit 773bf0e
Show file tree
Hide file tree
Showing 5 changed files with 187 additions and 42 deletions.
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
# itty-aws

This is a teeny-tiny AWS SDK implementation for TypeScript using [Proxies](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) that fits everything into `~14 KB`, including all Services and APIs. The name is an homage to the awesome [itty-router](https://github.com/kwhitley/itty-router).
This is a teeny-tiny AWS SDK implementation for TypeScript using [Proxies](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) that fits everything into `~49 KB`, including all Services and APIs. The name is an homage to the awesome [itty-router](https://github.com/kwhitley/itty-router).

> 🛠 This is a highly experimental API, do not use for anything serious.
## Supported APIs

Known to work:

- ✅ Any modern API using plain JSON protocol should work out of the box.
- ✅ DynamoDB
- ✅ EventBridge
- ✅ S3 `CreateBucket`, `GetObject`, `HeadObject`, `PutObject`, `DeleteObject`, `ListObjectsV2`
- ⛔️ S3 the remaining S3 APis likely don't work due to inconsistencies in the XML API.
- ⛔️ SQS (see: [#1](https://github.com/sam-goodwin/itty-aws/issues/1))
- ⛔️ SNS (see: [#2](https://github.com/sam-goodwin/itty-aws/issues/2))

## Why?

We want a lightweight AWS SDK that has no impact on Lambda cold starts and a standard API design. The AWS SDK v3 traded off usability to save on bundle size with the introduction of `client.send(Command)` and still didn't achieve a lightweight experience. None of this should be necessary - we can have our cake and eat it too!
Expand All @@ -21,8 +33,8 @@ This project aims to eliminate the following issues with the official AWS SDK:

The entire AWS SDK (including all Services and APIs) fits in to a

- Minified bundle size of: `14.6 KB`.
- Un-minified bundle size of: `26.4 KB`.
- Minified bundle size of: `49 KB`.
- Un-minified bundle size of: `95 KB`.

> 💪 It is possible to reduce this even further.
Expand All @@ -32,10 +44,6 @@ The entire AWS SDK (including all Services and APIs) fits in to a
npm install itty-aws
```

## Pre-requisites

- Node 18+ - we use the internal `fetch` API which is only available in Node 18.

## Usage

Import the top-level `AWS` object from `itty-aws` and instantiate a client for the Service you want. The SDK is constant size, so your performance is not impacted by the number or choice of AWS services. The APIs are methods on the client - just like AWS SDK v2, except minus the `.promise()`.
Expand Down Expand Up @@ -63,7 +71,5 @@ Instead of generating heavy classes and functions for the SDK like AWS does, we

## Known Issues

- It only works with Node 18+ because we use Node Fetch APIs. You can polyfill to work around this, but we should do better in the library to automatically handle older node versions while still maintaining a minimal bundle size.
- It has only been tested with some AWS DynamoDB APIs. More thorough testing and enumeration of APIs is required.
- Performance has not been tested - it's possible that our use of Fetch is slower than whatever magic the AWS SDK is doing.
- Performance has not been tested - it's possible that our use of `https` or `fetch` is slower than whatever magic the AWS SDK is doing.
- We're still importing some heavy code from the AWS SDK for signing requests - including tslib (for whatever reason). We should investigate hand-rolling replacements that don't have these dependencies or at least minimize them.
169 changes: 141 additions & 28 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export interface ClientOptions {
credentials?: AwsCredentialIdentity | Provider<AwsCredentialIdentity>;
}

declare const fetch: typeof import("node-fetch").default;
declare var fetch: typeof import("node-fetch").default;

var https: typeof import("https");
let httpAgent: import("https").Agent;

export const AWS: SDK = new Proxy({} as any, {
get: (_, className: keyof SDK) => {
Expand Down Expand Up @@ -55,17 +58,18 @@ export const AWS: SDK = new Proxy({} as any, {
body: JSON.stringify(input),
headers: {
// host is required by AWS Signature V4: https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
host: url.host,
Host: url.host,
"Accept-Encoding": "identity",
"Content-Type": resolveContentType(className),
"X-Amz-Target": resolveXAmzTarget(className, methodName),
"User-Agent": "itty-aws",
},
});

const isJson = response.headers
.get("content-type")
?.startsWith("application/x-amz-json");
const isJson = getHeader(
response.headers,
"Content-Type"
)?.startsWith("application/x-amz-json");

if (response.status === 200) {
return isJson ? response.json() : response.text();
Expand Down Expand Up @@ -99,6 +103,10 @@ export const AWS: SDK = new Proxy({} as any, {
? "GET"
: methodName.startsWith("put")
? "PUT"
: methodName.startsWith("head")
? "HEAD"
: methodName.startsWith("delete")
? "DELETE"
: "POST";

const url = new URL(
Expand All @@ -113,7 +121,7 @@ export const AWS: SDK = new Proxy({} as any, {
"Content-Type": "application/xml",
"User-Agent": "itty-aws",
"Accept-Encoding": "identity",
host: url.host,
Host: url.host,
},
method,
body:
Expand Down Expand Up @@ -150,20 +158,35 @@ export const AWS: SDK = new Proxy({} as any, {
throw new AWSError(errorMessage, errorCode);
}
} else {
const output =
xmlToJson(
(parsedXml?.children[0] as XmlDocument | undefined)?.children
) ?? {};
const c = (parsedXml?.children[0] as XmlDocument | undefined)
?.children;
let output = c ? xmlToJson(c) : {} ?? {};
if (methodName === "getObject") {
output.Body = responseText;
}
response.headers.forEach((value, key) => {
if (typeof response.headers.forEach === "function") {
response.headers.forEach((value, key) =>
reverseHeaders(key, value)
);
} else {
Object.entries(response.headers).forEach(([key, value]) =>
reverseHeaders(key, value)
);
}
output = Object.fromEntries(
Object.entries(output).map(([k, v]) => [
k,
s3NumberFields.has(k) ? parseNumber(v as string) : v,
])
);
return output;

function reverseHeaders(key: string, value: string) {
const k = s3ReverseHeaderMappings[key];
if (k) {
output[k] = value;
}
});
return output;
}
}

type Xml = XmlDocument | XmlElement | XmlComment | XmlText;
Expand Down Expand Up @@ -202,14 +225,7 @@ export const AWS: SDK = new Proxy({} as any, {
];
} else if (xml instanceof XmlText) {
if (name && s3NumberFields.has(name)) {
let i = parseInt(xml.text, 10);
if (isNaN(i)) {
i = parseFloat(xml.text);
}
if (isNaN(i)) {
return xml.text;
}
return i;
return parseNumber(xml.text);
}
if (xml.text.startsWith('"') && xml.text.startsWith('"')) {
// ETag is coming back quoted, wtf?
Expand Down Expand Up @@ -260,6 +276,15 @@ export const AWS: SDK = new Proxy({} as any, {
path: url.pathname,
protocol: url.protocol,
...init,
headers: {
...init.headers,
...(typeof fetch === "undefined"
? {
// fetch automatically puts the Content-Length header, https does not
"Content-Length": init.body?.length.toString(10) ?? "0",
}
: {}),
},
});

const signer = new SignatureV4({
Expand All @@ -271,18 +296,102 @@ export const AWS: SDK = new Proxy({} as any, {

const signedRequest = await signer.sign(request);

return fetch(url.toString(), {
headers: signedRequest.headers,
body: signedRequest.body,
method: signedRequest.method,
});
if (typeof fetch !== "undefined") {
// we're probably
return fetch(url.toString(), {
headers: signedRequest.headers,
body: signedRequest.body,
method: signedRequest.method,
});
} else {
const http = (https ??= await import("https"));
const agent = (httpAgent ??= new http.Agent({
keepAlive: true,
}));

return new Promise<HttpResponse>((resolve, reject) => {
const request = http.request(
url,
{
headers: signedRequest.headers,
method: signedRequest.method,
agent,
},
(msg) => {
const chunks: Buffer[] | string[] = [];
let isBuffer: boolean;
msg.on("data", (chunk) => {
chunks.push(chunk);
if (Buffer.isBuffer(chunk)) {
isBuffer = true;
} else {
isBuffer = false;
}
});
msg.on("error", (err) => {
reject(err);
});
msg.on("close", () => {
const body = isBuffer
? Buffer.concat(chunks as Buffer[])
: chunks.join("");
const text = () =>
typeof body === "string" ? body : body.toString("utf8");
resolve({
status: msg.statusCode!,
headers: msg.headers,
text: () => Promise.resolve(text()),
json: async () => JSON.parse(text()),
});
});
}
);

if (signedRequest.body) {
request.write(signedRequest.body);
}
request.end();
});
}
}
}
};
},
});

const s3NumberFields = new Set(["ObjectSize", "Size", "MaxKeys", "KeyCount"]);
function parseNumber(num: string) {
let i = parseInt(num, 10);
if (isNaN(i)) {
i = parseFloat(num);
}
if (isNaN(i)) {
return num;
}
return i;
}

function getHeader(headers: any, key: string): string | undefined {
if (typeof headers.get === "function") {
return headers.get(key);
} else {
return headers[key] ?? headers[key.toLocaleLowerCase()];
}
}

interface HttpResponse {
status: number;
headers: Record<string, string | string[] | undefined>;
text(): Promise<string>;
json(): Promise<any>;
}

const s3NumberFields = new Set([
"ObjectSize",
"Size",
"MaxKeys",
"KeyCount",
"ContentLength",
]);

const s3ArrayFields = new Set([
"Contents",
Expand Down Expand Up @@ -335,12 +444,16 @@ const s3HeaderMappings: {
ContentEncoding: "Content-Encoding",
CacheControl: "Cache-Control",
ContentLanguage: "Content-Language",
ContentLength: "Content-Length",
ContentType: "Content-Type",
ACL: "x-amz-acl",
};

const s3ReverseHeaderMappings = Object.fromEntries(
Object.entries(s3HeaderMappings).map(([k, v]) => [v, k])
Object.entries(s3HeaderMappings).flatMap(([k, v]) => [
[v, k],
[v.toLocaleLowerCase(), k],
])
);

function toKebabCase(pascal: string) {
Expand Down
29 changes: 27 additions & 2 deletions test/s3.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ReadableStream } from "stream/web";
import { AWS } from "../src";

import { S3BucketName } from "./constants.js";
Expand All @@ -8,7 +9,7 @@ const Key = "test-key";
const Body = "test-body";

describe("s3", () => {
test("S3 PutObject and GetObject should work", async () => {
test("S3 PutObject, GetObject, HeadObject", async () => {
await S3.putObject({
Bucket: S3BucketName,
Key,
Expand All @@ -21,10 +22,34 @@ describe("s3", () => {
});

expect(response?.Body?.toString()).toEqual(Body);

const head = await S3.headObject({
Bucket: S3BucketName,
Key,
});

expect(head.ContentLength).toEqual(9);

await S3.deleteObject({
Bucket: S3BucketName,
Key,
});
});

test("listObjectsV2", async () => {
const response = await S3.listObjectsV2({
let response = await S3.listObjectsV2({
Bucket: S3BucketName,
});

expect(response.Contents).toBe(undefined);

await S3.putObject({
Bucket: S3BucketName,
Key,
Body,
});

response = await S3.listObjectsV2({
Bucket: S3BucketName,
});

Expand Down
4 changes: 3 additions & 1 deletion tsconfig.esm.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"compilerOptions": {
"outDir": "lib",
"module": "ESNext",
"moduleResolution": "NodeNext"
"moduleResolution": "NodeNext",
"types": ["@types/node"],
"typeRoots": ["node_modules/@types"]
}
}
1 change: 0 additions & 1 deletion tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"exclude": ["lib", "node_modules", "src/package.json"],
"compilerOptions": {
"moduleResolution": "Node",
"types": ["@types/jest"],
"noEmit": true
}
}

0 comments on commit 773bf0e

Please sign in to comment.