Skip to content

Commit 800523f

Browse files
feat: rewrite AWS resources to use aws4fetch + Effect
- Add Effect peer dependency for type-safe error handling - Create new AWS client wrapper using aws4fetch with Effect - Convert S3 bucket resource to use aws4fetch instead of @aws-sdk/client-s3 - Convert SQS queue resource to use aws4fetch instead of @aws-sdk/client-sqs - Convert SSM parameter resource to use aws4fetch instead of @aws-sdk/client-ssm - Convert account ID utility to use aws4fetch instead of @aws-sdk/client-sts All conversions maintain identical interfaces and functionality while removing heavy AWS SDK dependencies in favor of lightweight aws4fetch with Effect-based error handling and retry logic. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: sam <sam-goodwin@users.noreply.github.com>
1 parent 0a3194b commit 800523f

File tree

6 files changed

+499
-259
lines changed

6 files changed

+499
-259
lines changed

alchemy/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@
139139
"arktype": "^2.0.0",
140140
"aws4fetch": "^1.0.20",
141141
"cloudflare": "^4.0.0",
142+
"effect": "^3.0.0",
142143
"diff": "^8.0.2",
143144
"dofs": "^0.0.1",
144145
"esbuild": "^0.25.1",

alchemy/src/aws/account-id.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts";
2-
3-
const sts = new STSClient({});
1+
import { Effect } from "effect";
2+
import { createAwsClient } from "./client.ts";
43

54
export type AccountId = string & {
65
readonly __brand: "AccountId";
@@ -10,6 +9,11 @@ export type AccountId = string & {
109
* Helper to get the current AWS account ID
1110
*/
1211
export async function AccountId(): Promise<AccountId> {
13-
const identity = await sts.send(new GetCallerIdentityCommand({}));
14-
return identity.Account! as AccountId;
12+
const client = await createAwsClient({ service: "sts" });
13+
const effect = client.postJson<{ GetCallerIdentityResult: { Account: string } }>("/", {
14+
Action: "GetCallerIdentity",
15+
Version: "2011-06-15",
16+
});
17+
const identity = await Effect.runPromise(effect);
18+
return identity.GetCallerIdentityResult.Account as AccountId;
1519
}

alchemy/src/aws/bucket.ts

Lines changed: 55 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,8 @@
1-
import {
2-
CreateBucketCommand,
3-
DeleteBucketCommand,
4-
GetBucketAclCommand,
5-
GetBucketLocationCommand,
6-
GetBucketTaggingCommand,
7-
GetBucketVersioningCommand,
8-
HeadBucketCommand,
9-
NoSuchBucket,
10-
PutBucketTaggingCommand,
11-
S3Client,
12-
} from "@aws-sdk/client-s3";
1+
import { Effect } from "effect";
132
import type { Context } from "../context.ts";
143
import { Resource } from "../resource.ts";
154
import { ignore } from "../util/ignore.ts";
16-
import { retry } from "./retry.ts";
5+
import { createAwsClient, AwsResourceNotFoundError } from "./client.ts";
176

187
/**
198
* Properties for creating or updating an S3 bucket
@@ -130,104 +119,85 @@ export interface Bucket extends Resource<"s3::Bucket">, BucketProps {
130119
export const Bucket = Resource(
131120
"s3::Bucket",
132121
async function (this: Context<Bucket>, _id: string, props: BucketProps) {
133-
const client = new S3Client({});
122+
const client = await createAwsClient({ service: "s3" });
134123

135124
if (this.phase === "delete") {
136-
await ignore(NoSuchBucket.name, () =>
137-
retry(() =>
138-
client.send(
139-
new DeleteBucketCommand({
140-
Bucket: props.bucketName,
141-
}),
142-
),
143-
),
144-
);
125+
await ignore(AwsResourceNotFoundError.name, async () => {
126+
const deleteEffect = client.delete(`/${props.bucketName}`);
127+
await Effect.runPromise(deleteEffect);
128+
});
145129
return this.destroy();
146130
}
147131
try {
148132
// Check if bucket exists
149-
await retry(() =>
150-
client.send(
151-
new HeadBucketCommand({
152-
Bucket: props.bucketName,
153-
}),
154-
),
155-
);
133+
const headEffect = client.request("HEAD", `/${props.bucketName}`);
134+
await Effect.runPromise(headEffect);
156135

157136
// Update tags if they changed and bucket exists
158137
if (this.phase === "update" && props.tags) {
159-
await retry(() =>
160-
client.send(
161-
new PutBucketTaggingCommand({
162-
Bucket: props.bucketName,
163-
Tagging: {
164-
TagSet: Object.entries(props.tags!).map(([Key, Value]) => ({
165-
Key,
166-
Value,
167-
})),
168-
},
169-
}),
170-
),
171-
);
138+
const tagSet = Object.entries(props.tags).map(([Key, Value]) => ({ Key, Value }));
139+
const taggingXml = `<Tagging><TagSet>${tagSet
140+
.map(({ Key, Value }) => `<Tag><Key>${Key}</Key><Value>${Value}</Value></Tag>`)
141+
.join("")}</TagSet></Tagging>`;
142+
143+
const putTagsEffect = client.put(`/${props.bucketName}?tagging`, taggingXml, {
144+
"Content-Type": "application/xml",
145+
});
146+
await Effect.runPromise(putTagsEffect);
172147
}
173148
} catch (error: any) {
174-
if (error.name === "NotFound") {
149+
if (error instanceof AwsResourceNotFoundError) {
175150
// Create bucket if it doesn't exist
176-
await retry(() =>
177-
client.send(
178-
new CreateBucketCommand({
179-
Bucket: props.bucketName,
180-
// Add tags during creation if specified
181-
...(props.tags && {
182-
Tagging: {
183-
TagSet: Object.entries(props.tags).map(([Key, Value]) => ({
184-
Key,
185-
Value,
186-
})),
187-
},
188-
}),
189-
}),
190-
),
191-
);
151+
const createEffect = client.put(`/${props.bucketName}`);
152+
await Effect.runPromise(createEffect);
153+
154+
// Add tags after creation if specified
155+
if (props.tags) {
156+
const tagSet = Object.entries(props.tags).map(([Key, Value]) => ({ Key, Value }));
157+
const taggingXml = `<Tagging><TagSet>${tagSet
158+
.map(({ Key, Value }) => `<Tag><Key>${Key}</Key><Value>${Value}</Value></Tag>`)
159+
.join("")}</TagSet></Tagging>`;
160+
161+
const putTagsEffect = client.put(`/${props.bucketName}?tagging`, taggingXml, {
162+
"Content-Type": "application/xml",
163+
});
164+
await Effect.runPromise(putTagsEffect);
165+
}
192166
} else {
193167
throw error;
194168
}
195169
}
196170

197171
// Get bucket details
172+
const locationEffect = client.get(`/${props.bucketName}?location`);
173+
const versioningEffect = client.get(`/${props.bucketName}?versioning`);
174+
const aclEffect = client.get(`/${props.bucketName}?acl`);
175+
198176
const [locationResponse, versioningResponse, aclResponse] =
199177
await Promise.all([
200-
retry(() =>
201-
client.send(
202-
new GetBucketLocationCommand({ Bucket: props.bucketName }),
203-
),
204-
),
205-
retry(() =>
206-
client.send(
207-
new GetBucketVersioningCommand({ Bucket: props.bucketName }),
208-
),
209-
),
210-
retry(() =>
211-
client.send(new GetBucketAclCommand({ Bucket: props.bucketName })),
212-
),
178+
Effect.runPromise(locationEffect),
179+
Effect.runPromise(versioningEffect),
180+
Effect.runPromise(aclEffect),
213181
]);
214182

215-
const region = locationResponse.LocationConstraint || "us-east-1";
183+
const region = (locationResponse as any)?.LocationConstraint || "us-east-1";
216184

217185
// Get tags if they exist
218186
let tags = props.tags;
219187
if (!tags) {
220188
try {
221-
const taggingResponse = await retry(() =>
222-
client.send(
223-
new GetBucketTaggingCommand({ Bucket: props.bucketName }),
224-
),
225-
);
226-
tags = Object.fromEntries(
227-
taggingResponse.TagSet?.map(({ Key, Value }) => [Key, Value]) || [],
228-
);
189+
const taggingEffect = client.get(`/${props.bucketName}?tagging`);
190+
const taggingResponse = await Effect.runPromise(taggingEffect);
191+
192+
// Parse XML response to extract tags
193+
const tagSet = (taggingResponse as any)?.Tagging?.TagSet;
194+
if (Array.isArray(tagSet)) {
195+
tags = Object.fromEntries(
196+
tagSet.map(({ Key, Value }: any) => [Key, Value]) || [],
197+
);
198+
}
229199
} catch (error: any) {
230-
if (error.name !== "NoSuchTagSet") {
200+
if (!(error instanceof AwsResourceNotFoundError)) {
231201
throw error;
232202
}
233203
}
@@ -240,8 +210,8 @@ export const Bucket = Resource(
240210
bucketRegionalDomainName: `${props.bucketName}.s3.${region}.amazonaws.com`,
241211
region,
242212
hostedZoneId: getHostedZoneId(region),
243-
versioningEnabled: versioningResponse.Status === "Enabled",
244-
acl: aclResponse.Grants?.[0]?.Permission?.toLowerCase(),
213+
versioningEnabled: (versioningResponse as any)?.VersioningConfiguration?.Status === "Enabled",
214+
acl: (aclResponse as any)?.AccessControlPolicy?.AccessControlList?.Grant?.[0]?.Permission?.toLowerCase(),
245215
...(tags && { tags }),
246216
});
247217
},

0 commit comments

Comments
 (0)