From f6d94f18b338739c6da50cf54768fec5b8862481 Mon Sep 17 00:00:00 2001 From: Naor Peled Date: Mon, 30 Jan 2023 03:09:09 +0200 Subject: [PATCH] feat: add S3 support (#19) --- package.json | 3 +- pnpm-lock.yaml | 7 + scripts/gen-sdk-mappings.mts | 184 ++-------------- scripts/smithy.ts | 154 +++++++++++++ src/index.ts | 409 ++++++++++++++++++++++++++++++----- test/constants.ts | 2 + test/s3.test.ts | 50 +++++ test/setup.ts | 26 ++- 8 files changed, 613 insertions(+), 222 deletions(-) create mode 100644 scripts/smithy.ts create mode 100644 test/s3.test.ts diff --git a/package.json b/package.json index 710a323..71975aa 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "bench:hotswap": "pnpm -r --filter @benchmark/infra run hotswap" }, "dependencies": { + "@rgrove/parse-xml": "^4.0.1", "aws-sdk": "2.1295.0" }, "peerDependencies": { @@ -42,8 +43,8 @@ "@types/prettier": "^2.7.2", "esbuild": "0.17.2", "esbuild-visualizer": "^0.4.0", - "node-fetch": "^3.3.0", "jest": "^29", + "node-fetch": "^3.3.0", "prettier": "^2.8.3", "standard-version": "^9.5.0", "ts-jest": "^29", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb0ef1a..cca9565 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,7 @@ importers: '@aws-sdk/protocol-http': 3.226.0 '@aws-sdk/signature-v4': 3.226.0 '@aws-sdk/types': 3.226.0 + '@rgrove/parse-xml': ^4.0.1 '@tsconfig/node16': ^1 '@types/jest': ^29 '@types/node': ^16 @@ -24,6 +25,7 @@ importers: ts-node: ^10.9.1 typescript: ^4.9.4 dependencies: + '@rgrove/parse-xml': 4.0.1 aws-sdk: 2.1295.0 devDependencies: '@aws-crypto/sha256-js': 3.0.0 @@ -1667,6 +1669,11 @@ packages: '@jridgewell/sourcemap-codec': 1.4.14 dev: true + /@rgrove/parse-xml/4.0.1: + resolution: {integrity: sha512-ZjEohvhVVzr7cvwc7UqdqWShDlh2y79N1o6wlEIDLX7cpKVrLI9zTqT8etArqumnaGrxgyAE2/11KF8cozHsrA==} + engines: {node: '>=14.0.0'} + dev: false + /@sinclair/typebox/0.24.51: resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} dev: true diff --git a/scripts/gen-sdk-mappings.mts b/scripts/gen-sdk-mappings.mts index fc32399..a3b2881 100644 --- a/scripts/gen-sdk-mappings.mts +++ b/scripts/gen-sdk-mappings.mts @@ -1,10 +1,9 @@ import fs from "fs/promises"; import path from "path"; +import { findService, loadSmithyFile, SmithyFile } from "./smithy.js"; const modelsDir = "aws-models"; -await parseErrors(path.join(modelsDir, "dynamodb.json")); - const specifications = await Promise.all( (await fs.readdir(modelsDir)) .map((file) => path.join(modelsDir, file)) @@ -87,169 +86,22 @@ async function parseServiceMappings( } } -async function parseErrors(serviceFile: string) { - const serviceSpec = preProcessSmithyFile( - JSON.parse((await fs.readFile(serviceFile)).toString("utf-8")) - ); - - const service = findService(serviceSpec); +const s3 = await loadSmithyFile(path.join(modelsDir, "s3.json")); - for (const [shapeFQN, shape] of Object.entries(serviceSpec.shapes)) { - if (shape.type === "structure" && shape.traits?.["smithy.api#error"]) { - delete shape.traits["smithy.api#documentation"]; - // console.log(shape); +const s3Headers = Object.values(s3.shapes) + .flatMap((shape) => + shape.type === "structure" + ? Object.entries(shape.members).flatMap(([memberName, member]) => + member.traits?.["smithy.api#httpHeader"] + ? [[memberName, member.traits["smithy.api#httpHeader"]] as const] + : [] + ) + : [] + ) + .reduce((map: any, [k, v]) => { + if (k in map && map[k] !== v) { + console.log(k, v, map[k]); } - } -} - -async function loadSmithyFile(file: string): Promise { - return preProcessSmithyFile( - JSON.parse((await fs.readFile(file)).toString("utf-8")) - ); -} - -// adds the FQN onto each Shape -function preProcessSmithyFile(file: SmithyFile) { - Object.entries(file.shapes).forEach(([fqn, shape]) => (shape.fqn = fqn)); - return file; -} - -function findService(file: SmithyFile): ServiceShape | undefined { - const services = Object.values(file.shapes).filter( - (shape): shape is ServiceShape => shape.type === "service" - ); - if (services.length === 0) { - return undefined; - } else if (services.length > 1) { - throw new Error(`could not resolve a single service for the file`); - } else { - return services[0]!; - } -} - -interface SmithyFile { - shapes: { - [fqn: string]: Shape; - }; -} - -type Shape = - | BlobShape - | BooleanShape - | DoubleShape - | EnumShape - | IntegerShape - | ListShape - | LongShape - | MapShape - | OperationShape - | ServiceShape - | StringShape - | StructureShape - | TimestampShape - | UnionShape; - -interface HasTraits { - traits?: Traits; -} - -interface BaseShape extends HasTraits { - fqn: string; -} - -interface Traits { - [traitFqn: string]: any; - "smithy.api#error"?: "server" | "client"; - "smithy.api#documentation"?: string; - "aws.api#service"?: { - sdkId: string; - arnNamespace: string; - cloudFormationName: string; - cloudTrailEventSource: string; - endpointPrefix: string; - }; - "aws.auth#sigv4"?: { - name: string; - }; - "smithy.api#title"?: string; - "smithy.api#xmlNamespace"?: { - uri: string; - }; - "aws.protocols#awsJson1_0"?: {}; - "aws.protocols#awsJson1_1"?: {}; -} - -interface ServiceShape extends BaseShape { - type: "service"; - version: string; -} - -interface OperationShape extends BaseShape { - type: "operation"; -} - -interface StringShape extends BaseShape { - type: "string"; -} - -interface BlobShape extends BaseShape { - type: "blob"; -} - -interface IntegerShape extends BaseShape { - type: "integer"; -} - -interface LongShape extends BaseShape { - type: "long"; -} - -interface DoubleShape extends BaseShape { - type: "double"; -} - -interface BooleanShape extends BaseShape { - type: "boolean"; -} - -interface TimestampShape extends BaseShape { - type: "timestamp"; -} - -interface EnumShape extends BaseShape { - type: "enum"; -} - -interface StructureShape extends BaseShape { - type: "structure"; - members: { - [memberName: string]: Member; - }; -} - -interface Member extends HasTraits { - target: string; -} - -interface ListShape extends BaseShape { - type: "list"; - member: { - target: string; - }; -} -interface MapShape extends BaseShape { - type: "map"; - key: { - target: string; - }; - value: { - target: string; - }; -} - -interface UnionShape extends BaseShape { - type: "union"; - members: { - [memberName: string]: Member; - }; -} + map[k] = v; + return map; + }, {}); diff --git a/scripts/smithy.ts b/scripts/smithy.ts new file mode 100644 index 0000000..cf2cebd --- /dev/null +++ b/scripts/smithy.ts @@ -0,0 +1,154 @@ +import fs from "fs/promises"; + +export async function loadSmithyFile(file: string): Promise { + return preProcessSmithyFile( + JSON.parse((await fs.readFile(file)).toString("utf-8")) + ); +} + +// adds the FQN onto each Shape +export function preProcessSmithyFile(file: SmithyFile) { + Object.entries(file.shapes).forEach(([fqn, shape]) => (shape.fqn = fqn)); + return file; +} + +export function findService(file: SmithyFile): ServiceShape | undefined { + const services = Object.values(file.shapes).filter( + (shape): shape is ServiceShape => shape.type === "service" + ); + if (services.length === 0) { + return undefined; + } else if (services.length > 1) { + throw new Error(`could not resolve a single service for the file`); + } else { + return services[0]!; + } +} + +export interface SmithyFile { + shapes: { + [fqn: string]: Shape; + }; +} + +export type Shape = + | BlobShape + | BooleanShape + | DoubleShape + | EnumShape + | IntegerShape + | ListShape + | LongShape + | MapShape + | OperationShape + | ServiceShape + | StringShape + | StructureShape + | TimestampShape + | UnionShape; + +export interface HasTraits { + traits?: Traits; +} + +export interface BaseShape extends HasTraits { + fqn: string; +} + +export interface Traits { + [traitFqn: string]: any; + "smithy.api#error"?: "server" | "client"; + "smithy.api#documentation"?: string; + "aws.api#service"?: { + sdkId: string; + arnNamespace: string; + cloudFormationName: string; + cloudTrailEventSource: string; + endpointPrefix: string; + }; + "aws.auth#sigv4"?: { + name: string; + }; + "smithy.api#title"?: string; + "smithy.api#xmlNamespace"?: { + uri: string; + }; + "aws.protocols#awsJson1_0"?: {}; + "aws.protocols#awsJson1_1"?: {}; + "smithy.api#httpHeader"?: string; +} + +export interface ServiceShape extends BaseShape { + type: "service"; + version: string; +} + +export interface OperationShape extends BaseShape { + type: "operation"; +} + +export interface StringShape extends BaseShape { + type: "string"; +} + +export interface BlobShape extends BaseShape { + type: "blob"; +} + +export interface IntegerShape extends BaseShape { + type: "integer"; +} + +export interface LongShape extends BaseShape { + type: "long"; +} + +export interface DoubleShape extends BaseShape { + type: "double"; +} + +export interface BooleanShape extends BaseShape { + type: "boolean"; +} + +export interface TimestampShape extends BaseShape { + type: "timestamp"; +} + +export interface EnumShape extends BaseShape { + type: "enum"; +} + +export interface StructureShape extends BaseShape { + type: "structure"; + members: { + [memberName: string]: Member; + }; +} + +export interface Member extends HasTraits { + target: string; +} + +export interface ListShape extends BaseShape { + type: "list"; + member: { + target: string; + }; +} +export interface MapShape extends BaseShape { + type: "map"; + key: { + target: string; + }; + value: { + target: string; + }; +} + +export interface UnionShape extends BaseShape { + type: "union"; + members: { + [memberName: string]: Member; + }; +} diff --git a/src/index.ts b/src/index.ts index 43fa004..8da7eea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,13 @@ import type { AwsCredentialIdentity, Provider } from "@aws-sdk/types"; import type { SDK } from "./sdk.generated.js"; import { mappings } from "./mappings.js"; +import { + parseXml, + XmlComment, + XmlDocument, + XmlElement, + XmlText, +} from "@rgrove/parse-xml"; export interface ClientOptions { endpoint?: string; @@ -31,70 +38,364 @@ export const AWS: SDK = new Proxy({} as any, { {}, { get: (_target, methodName: string) => { - return async (input: any) => { - const url = new URL(`https://${endpoint}`); - - // See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.LowLevelAPI.html - - const request = new HttpRequest({ - hostname: url.hostname, - path: url.pathname, - protocol: url.protocol, - method: "POST", - 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, - "Accept-Encoding": "identity", - "Content-Type": resolveContentType(className), - "X-Amz-Target": resolveXAmzTarget(className, methodName), - "User-Agent": "itty-aws", - }, - }); - - const signer = new SignatureV4({ - credentials, - service: resolveService(className), - region, - sha256: Sha256, - }); - - const signedRequest = await signer.sign(request); - - const response = await fetch(url.toString(), { - headers: signedRequest.headers, - body: signedRequest.body, - method: signedRequest.method, - }); - - const isJson = response.headers - .get("content-type") - ?.startsWith("application/x-amz-json"); - - if (response.status === 200) { - return isJson ? response.json() : response.text(); - } else { - // see: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html - // for now we'll just throw the error as a json object - // TODO: throw something that is easy to branch on and check instanceof - this may increase bundle size though - throw isJson - ? new AWSError(await response.json()) - : new Error(await response.text()); - } - }; + if (className === "S3") { + return createS3Handler(methodName as any); + } + return createDefaultHandler(methodName); }, } ); + + function createDefaultHandler(methodName: string) { + return async (input: any) => { + const url = new URL(`https://${endpoint}`); + + const response = await sendRequest(url, { + method: "POST", + 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, + "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"); + + if (response.status === 200) { + return isJson ? response.json() : response.text(); + } else { + // see: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html + // for now we'll just throw the error as a json object + // TODO: throw something that is easy to branch on and check instanceof - this may increase bundle size though + + throw isJson + ? new AWSError(await response.json()) + : new Error(await response.text()); + } + }; + } + + function createS3Handler(methodName: SDKMethods<"S3">) { + return async function (input: SDKInputProperties<"S3">) { + const bucket = input.Bucket; + const key = input.Key; + + const headers = Object.fromEntries( + (Object.keys(input) as (keyof typeof input)[]).flatMap((k) => + k in mappings ? [[s3HeaderMappings[k], input[k]]] : [] + ) + ); + + const method = + methodName === "createBucket" + ? "PUT" + : methodName.startsWith("get") || methodName.startsWith("list") + ? "GET" + : methodName.startsWith("put") + ? "PUT" + : "POST"; + + const url = new URL( + `https://${bucket ? `${bucket}.` : ""}${endpoint}${ + typeof key === "string" ? `/${key}` : "" + }${method === "GET" ? toQueryString() : ""}` + ); + + const response = await sendRequest(url, { + headers: { + ...headers, + "Content-Type": "application/xml", + "User-Agent": "itty-aws", + "Accept-Encoding": "identity", + host: url.host, + }, + method, + body: + methodName === "createBucket" + ? `${process.env.AWS_REGION}` + : methodName === "putObject" + ? typeof input.Body === "string" + ? input.Body + : Buffer.isBuffer(input.Body) + ? input.Body + : undefined + : undefined, + }); + + const responseText = await response.text(); + + const parsedXml = + responseText !== "" ? parseXml(responseText) : undefined; + if (response.status < 200 || response.status >= 300) { + const errorXmlObject = (parsedXml?.children[0] as XmlElement) + .children as XmlElement[]; + const errorCode = ( + errorXmlObject.find((child) => child.name === "Code") + ?.children[0] as XmlElement + ).text; + const errorMessage = ( + errorXmlObject.find((child) => child.name === "Message") + ?.children[0] as XmlElement + ).text; + + if (errorCode) { + throw new AWSError(errorMessage, errorCode); + } + } else { + const output = + xmlToJson( + (parsedXml?.children[0] as XmlDocument | undefined)?.children + ) ?? {}; + if (methodName === "getObject") { + output.Body = responseText; + } + response.headers.forEach((value, key) => { + const k = s3ReverseHeaderMappings[key]; + if (k) { + output[k] = value; + } + }); + return output; + } + + type Xml = XmlDocument | XmlElement | XmlComment | XmlText; + function xmlToJson( + xml: Xml[] | Xml | undefined, + name?: string + ): any { + if (xml === undefined) { + return []; + } else if (xml instanceof XmlDocument) { + return xml.document.children.flatMap((x) => xmlToJson(x, name)); + } else if (Array.isArray(xml)) { + return xml + .flatMap((x) => + x instanceof XmlElement ? [xmlToJson(x)] : [] + ) + .reduce( + (a, [k, v]) => ({ + ...a, + [k]: s3ArrayFields.has(k) + ? k in a + ? [...a[k], v] + : [v] + : v, + }), + {} + ); + } else if (xml instanceof XmlElement) { + return [ + xml.name, + xml.children.length === 0 + ? undefined + : xml.children.length === 1 + ? xmlToJson(xml.children[0], xml.name) + : xmlToJson(xml.children, xml.name), + ]; + } 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; + } + if (xml.text.startsWith('"') && xml.text.startsWith('"')) { + // ETag is coming back quoted, wtf? + return xml.text.slice(1, xml.text.length - 1); + } + return xml.text === "true" + ? true + : xml.text === "false" + ? false + : xml.text; + } + return []; + } + + function toQueryString() { + const q = Object.entries(input) + .flatMap(([k, v]) => { + if (k in s3HeaderMappings || k === "Bucket" || k === "Key") { + return []; + } else { + return [ + [ + `${ + s3QueryStringMappings[ + k as keyof typeof s3QueryStringMappings + ] ?? toKebabCase(k) + }=${v}`, + ], + ]; + } + }) + .join("&"); + return q ? `?${q}` : ""; + } + }; + } + + async function sendRequest( + url: URL, + init: { + method: string; + body?: string | Buffer; + headers: Record; + } + ) { + const request = new HttpRequest({ + hostname: url.hostname, + path: url.pathname, + protocol: url.protocol, + ...init, + }); + + const signer = new SignatureV4({ + credentials, + service: resolveService(className), + region, + sha256: Sha256, + }); + + const signedRequest = await signer.sign(request); + + return fetch(url.toString(), { + headers: signedRequest.headers, + body: signedRequest.body, + method: signedRequest.method, + }); + } } }; }, }); +const s3NumberFields = new Set(["ObjectSize", "Size", "MaxKeys", "KeyCount"]); + +const s3ArrayFields = new Set([ + "Contents", +] satisfies (keyof SDKOutputProperties<"S3">)[]); + +const s3QueryStringMappings: { + [k in keyof SDKInputProperties<"S3">]?: string; +} = { + PartNumber: "partNumber", +}; + +// todo: handle generally +const s3HeaderMappings: { + [k in keyof SDKInputProperties<"S3">]?: string; +} = { + BucketKeyEnabled: sse("bucket-key-enabled"), + BypassGovernanceRetention: amz("bypass-governance-retention"), + ChecksumCRC32: amz("checksum-crc32"), + ChecksumCRC32C: amz("checksum-crc32c"), + ChecksumSHA1: amz("checksum-sha1"), + ChecksumSHA256: amz("checksum-sha256"), + CopySource: copy("source"), + CopySourceIfMatch: copy("source-if-match"), + CopySourceIfModifiedSince: copy("source-if-modified-since"), + CopySourceIfNoneMatch: copy("source-if-none-match"), + CopySourceIfUnmodifiedSince: copy("source-if-unmodified-since"), + ExpectedBucketOwner: amz("expected-bucket-owner"), + Expires: "Expires", + GrantFullControl: amz("grant-full-control"), + GrantRead: amz("grant-read"), + GrantReadACP: amz("grant-read-acp"), + GrantWrite: amz("grant-write"), + GrantWriteACP: amz("grant-write-acp"), + MFA: amz("mfa"), + ObjectLockLegalHoldStatus: amz("object-lock-legal-hold"), + ObjectLockMode: amz("object-lock-mode"), + ObjectLockRetainUntilDate: amz("object-lock-retain-until-date"), + RequestPayer: amz("request-payer"), + ServerSideEncryption: amz("sever-side-encryption"), + SSECustomerAlgorithm: sse("customer-algorithm"), + SSECustomerKey: sse("customer-key"), + SSECustomerKeyMD5: sse("customer-key-MD5"), + SSEKMSEncryptionContext: sse("context"), + StorageClass: amz("storage-class"), + Tagging: amz("tagging"), + WebsiteRedirectLocation: amz("website-redirect-location"), + ObjectLockEnabledForBucket: amz("bucket-object-lock-enabled"), + ObjectOwnership: amz("object-ownership"), + ContentDisposition: "Content-Disposition", + ContentEncoding: "Content-Encoding", + CacheControl: "Cache-Control", + ContentLanguage: "Content-Language", + ContentType: "Content-Type", + ACL: "x-amz-acl", +}; + +const s3ReverseHeaderMappings = Object.fromEntries( + Object.entries(s3HeaderMappings).map(([k, v]) => [v, k]) +); + +function toKebabCase(pascal: string) { + return pascal.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase()).slice(1); +} + +function sse(s: string) { + return `x-amz-server-side-encryption-${s}`; +} + +function amz(s: string) { + return `x-amz-${s}`; +} + +function copy(s: string) { + return `x-amz-copy-${s}`; +} + +type SDKMethods = keyof InstanceType; + +type SDKInputProperties = UnionToIntersection< + { + [k in keyof InstanceType]: InstanceType< + SDK[Service] + >[k] extends { + (input: infer I): any; + } + ? I // Exclude any> + : never; + }[keyof InstanceType] +>; + +type SDKOutputProperties = UnionToIntersection< + { + [k in keyof InstanceType]: InstanceType< + SDK[Service] + >[k] extends { + (input: any): Promise; + } + ? I // Exclude any> + : never; + }[keyof InstanceType] +>; + +type UnionToIntersection = (T extends any ? (x: T) => any : never) extends ( + x: infer R +) => any + ? R + : never; + export class AWSError extends Error { readonly type?: string; - constructor(error: any) { - super(typeof error?.message === "string" ? error.message : error.__type); - this.type = error.__type; + constructor(error: any, type?: string) { + super( + typeof error?.message === "string" ? error.message : type ?? error.__type + ); + this.type = type ?? error.__type; } } diff --git a/test/constants.ts b/test/constants.ts index e4a4aad..225a63d 100644 --- a/test/constants.ts +++ b/test/constants.ts @@ -6,4 +6,6 @@ export const SsmParameterValue = "itty-parameter-value"; export const EventBusName = "itty-event-bus"; +export const S3BucketName = "itty-s3-bucket"; + process.env.AWS_REGION = "us-west-2"; diff --git a/test/s3.test.ts b/test/s3.test.ts new file mode 100644 index 0000000..beb2929 --- /dev/null +++ b/test/s3.test.ts @@ -0,0 +1,50 @@ +import { AWS } from "../src"; + +import { S3BucketName } from "./constants.js"; + +const S3 = new AWS.S3(); + +const Key = "test-key"; +const Body = "test-body"; + +describe("s3", () => { + test("S3 PutObject and GetObject should work", async () => { + await S3.putObject({ + Bucket: S3BucketName, + Key, + Body, + }); + + const response = await S3.getObject({ + Bucket: S3BucketName, + Key, + }); + + expect(response?.Body?.toString()).toEqual(Body); + }); + + test("listObjectsV2", async () => { + const response = await S3.listObjectsV2({ + Bucket: S3BucketName, + }); + + expect(response).toMatchObject({ + Contents: [ + { + Key, + ETag: expect.any(String), + LastModified: expect.any(String), + Owner: { + DisplayName: expect.any(String), + ID: expect.any(String), + }, + StorageClass: "STANDARD", + Size: 9, + }, + ], + MaxKeys: 1000, + IsTruncated: false, + Name: S3BucketName, + }); + }); +}); diff --git a/test/setup.ts b/test/setup.ts index 988b1a1..243a6a1 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,13 +1,19 @@ import { AWS, AWSError } from "../src/index.js"; import { EventBusName, + S3BucketName, SsmParameterName, SsmParameterValue, TableName, } from "./constants.js"; try { - await Promise.all([createTable(), createParameter(), createEventBus()]); + await Promise.all([ + createTable(), + createParameter(), + createEventBus(), + createS3Bucket(), + ]); } catch (err) { console.error(err); process.exit(1); @@ -89,3 +95,21 @@ async function createEventBus() { } } } + +async function createS3Bucket() { + const client = new AWS.S3(); + try { + await client.createBucket({ + Bucket: S3BucketName, + }); + } catch (err) { + if ( + !(err instanceof AWSError) || + (err.type !== "ResourceAlreadyExistsException" && + err.type !== "BucketAlreadyOwnedByYou") + ) { + console.error(err); + throw err; + } + } +}