Skip to content

Commit 6e5b107

Browse files
authored
feat(cloudflare): Worker, Assets, R2, KV (#13)
1 parent ff458df commit 6e5b107

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+2431
-470
lines changed

alchemy-effect/package.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,40 @@
7171
"import": "./lib/aws/credentials.js",
7272
"types": "./lib/aws/credentials.d.ts"
7373
},
74+
"./cli": {
75+
"bun": "./src/cli/index.ts",
76+
"import": "./lib/cli/index.js",
77+
"types": "./lib/cli/index.d.ts"
78+
},
7479
"./cloudflare": {
7580
"bun": "./src/cloudflare/index.ts",
7681
"import": "./lib/cloudflare/index.js",
7782
"types": "./lib/cloudflare/index.d.ts"
83+
},
84+
"./cloudflare/live": {
85+
"bun": "./src/cloudflare/live.ts",
86+
"import": "./lib/cloudflare/live.js",
87+
"types": "./lib/cloudflare/live.d.ts"
88+
},
89+
"./cloudflare/assets": {
90+
"bun": "./src/cloudflare/worker/assets.fetch.ts",
91+
"import": "./lib/cloudflare/worker/assets.fetch.js",
92+
"types": "./lib/cloudflare/worker/assets.fetch.d.ts"
93+
},
94+
"./cloudflare/worker": {
95+
"bun": "./src/cloudflare/worker/index.ts",
96+
"import": "./lib/cloudflare/worker/index.js",
97+
"types": "./lib/cloudflare/worker/index.d.ts"
98+
},
99+
"./cloudflare/kv": {
100+
"bun": "./src/cloudflare/kv/index.ts",
101+
"import": "./lib/cloudflare/kv/index.js",
102+
"types": "./lib/cloudflare/kv/index.d.ts"
103+
},
104+
"./cloudflare/r2": {
105+
"bun": "./src/cloudflare/r2/index.ts",
106+
"import": "./lib/cloudflare/r2/index.js",
107+
"types": "./lib/cloudflare/r2/index.d.ts"
78108
}
79109
},
80110
"dependencies": {
@@ -88,12 +118,14 @@
88118
"cloudflare": "catalog:",
89119
"esbuild": "^0.25.12",
90120
"fast-xml-parser": "^5.2.5",
121+
"ignore": "^7.0.5",
91122
"itty-aws": "catalog:",
92123
"jszip": "^3.10.1",
93124
"yaml": "^2.0.0"
94125
},
95126
"devDependencies": {
96127
"@clack/prompts": "^0.11.0",
128+
"@cloudflare/workers-types": "catalog:",
97129
"@effect/platform": "catalog:",
98130
"@effect/platform-node": "catalog:",
99131
"@types/aws-lambda": "catalog:",

alchemy-effect/src/aws/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as Layer from "effect/Layer";
2+
import * as ESBuild from "../esbuild.ts";
23
import * as Account from "./account.ts";
34
import * as Credentials from "./credentials.ts";
45
import * as DynamoDB from "./dynamodb/index.ts";
@@ -13,7 +14,10 @@ import * as STS from "./sts.ts";
1314
export * from "./profile.ts";
1415

1516
export const providers = Layer.mergeAll(
16-
Layer.provide(Lambda.functionProvider(), Lambda.client()),
17+
Layer.provide(
18+
Layer.provideMerge(Lambda.functionProvider(), ESBuild.layer()),
19+
Lambda.client(),
20+
),
1721
Layer.provide(SQS.queueProvider(), SQS.client()),
1822
Layer.provide(DynamoDB.tableProvider(), DynamoDB.client()),
1923
Layer.provide(EC2.vpcProvider(), EC2.client()),

alchemy-effect/src/binding.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export type AnyBinding<F extends Runtime = any> = Binding<
2323
>;
2424

2525
export interface Binding<
26-
Run extends Runtime,
26+
Run extends Runtime<any, any, any>,
2727
Cap extends Capability = Capability,
2828
Props = any,
2929
Attr extends Run["binding"] = any,
Lines changed: 141 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,152 @@
1-
import * as cf from "cloudflare";
1+
import { APIConnectionError, Cloudflare, type APIError } from "cloudflare";
2+
import {
3+
isRequestOptions,
4+
type APIPromise,
5+
type RequestOptions,
6+
} from "cloudflare/core";
7+
import type { ErrorData } from "cloudflare/resources";
8+
import { Layer } from "effect";
29
import * as Context from "effect/Context";
10+
import * as Data from "effect/Data";
311
import * as Effect from "effect/Effect";
4-
import * as Layer from "effect/Layer";
5-
import * as Option from "effect/Option";
612

7-
export class CloudflareApi extends Context.Tag("CloudflareApi")<
8-
CloudflareApi,
9-
cf.Cloudflare
10-
>() {}
11-
12-
export class CloudflareAccountId extends Context.Tag("CloudflareAccountId")<
13+
export class CloudflareAccountId extends Context.Tag("cloudflare/account-id")<
1314
CloudflareAccountId,
1415
string
15-
>() {}
16+
>() {
17+
static readonly fromEnv = Layer.effect(
18+
CloudflareAccountId,
19+
Effect.gen(function* () {
20+
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
21+
if (!accountId) {
22+
return yield* Effect.die("CLOUDFLARE_ACCOUNT_ID is not set");
23+
}
24+
return accountId;
25+
}),
26+
);
27+
}
1628

17-
export class CloudflareEmail extends Context.Tag("CloudflareEmail")<
18-
CloudflareEmail,
19-
string
20-
>() {}
29+
export class CloudflareApi extends Effect.Service<CloudflareApi>()(
30+
"cloudflare/api",
31+
{
32+
effect: (options?: {
33+
baseUrl?: string;
34+
apiToken?: string;
35+
apiKey?: string;
36+
apiEmail?: string;
37+
}) =>
38+
Effect.succeed(
39+
createRecursiveProxy(
40+
new Cloudflare({
41+
baseURL: options?.baseUrl ?? import.meta.env.CLOUDFLARE_BASE_URL,
42+
apiToken: options?.apiToken ?? import.meta.env.CLOUDFLARE_API_TOKEN,
43+
apiKey: options?.apiKey ?? import.meta.env.CLOUDFLARE_API_KEY,
44+
apiEmail: options?.apiEmail ?? import.meta.env.CLOUDFLARE_API_EMAIL,
45+
}),
46+
),
47+
),
48+
},
49+
) {}
2150

22-
export class CloudflareApiKey extends Context.Tag("CloudflareApiKey")<
23-
CloudflareApiKey,
24-
string
25-
>() {}
51+
export class CloudflareApiError extends Data.Error<{
52+
_tag:
53+
| "Connection"
54+
| "BadRequest"
55+
| "Authentication"
56+
| "PermissionDenied"
57+
| "NotFound"
58+
| "Conflict"
59+
| "UnprocessableEntity"
60+
| "RateLimit"
61+
| "InternalServerError"
62+
| "Unknown";
63+
message: string;
64+
errors: ErrorData[];
65+
cause: APIError;
66+
}> {
67+
static from(cause: APIError): CloudflareApiError {
68+
const error = new CloudflareApiError({
69+
_tag: CloudflareApiError.getTag(cause),
70+
message: cause.message,
71+
errors: cause.errors,
72+
cause: cause,
73+
});
74+
return error;
75+
}
2676

27-
export class CloudflareApiToken extends Context.Tag("CloudflareApiToken")<
28-
CloudflareApiToken,
29-
string
30-
>() {}
77+
private static getTag(error: APIError): CloudflareApiError["_tag"] {
78+
if (error instanceof APIConnectionError) {
79+
return "Connection";
80+
}
81+
switch (error.status) {
82+
case 400:
83+
return "BadRequest";
84+
case 401:
85+
return "Authentication";
86+
case 403:
87+
return "PermissionDenied";
88+
case 404:
89+
return "NotFound";
90+
case 409:
91+
return "Conflict";
92+
case 422:
93+
return "UnprocessableEntity";
94+
case 429:
95+
return "RateLimit";
96+
case 500:
97+
return "InternalServerError";
98+
default:
99+
return "Unknown";
100+
}
101+
}
102+
}
31103

32-
export class CloudflareBaseUrl extends Context.Tag("CloudflareBaseUrl")<
33-
CloudflareBaseUrl,
34-
string
35-
>() {}
104+
type ToEffect<T> = {
105+
[K in keyof T]: T[K] extends (...args: any[]) => any
106+
? (
107+
...args: Parameters<T[K]>
108+
) => Effect.Effect<UnwrapAPIPromise<ReturnType<T[K]>>, CloudflareApiError>
109+
: T[K] extends Record<string, any>
110+
? ToEffect<T[K]>
111+
: T[K];
112+
};
36113

37-
const tryGet = <Tag extends Context.Tag<any, any>>(
38-
tag: Tag,
39-
defaultValue: Tag["Service"] | undefined,
40-
) =>
41-
Effect.gen(function* () {
42-
const value = yield* Effect.serviceOption(tag);
43-
return Option.getOrElse(value, () => defaultValue);
44-
});
114+
type UnwrapAPIPromise<T> = T extends APIPromise<infer U> ? U : never;
45115

46-
export const cloudflareApi = Layer.effect(
47-
CloudflareApi,
48-
Effect.gen(function* () {
49-
const email = yield* tryGet(
50-
CloudflareEmail,
51-
import.meta.env.CLOUDFLARE_EMAIL,
52-
);
53-
const apiKey = yield* tryGet(
54-
CloudflareApiKey,
55-
import.meta.env.CLOUDFLARE_API_KEY,
56-
);
57-
const apiToken = yield* tryGet(
58-
CloudflareApiToken,
59-
import.meta.env.CLOUDFLARE_API_TOKEN,
60-
);
61-
const baseURL = yield* tryGet(
62-
CloudflareBaseUrl,
63-
import.meta.env.CLOUDFLARE_BASE_URL,
64-
);
65-
return new cf.Cloudflare({
66-
apiEmail: email,
67-
apiKey: apiKey,
68-
apiToken: apiToken,
69-
baseURL: baseURL,
70-
});
71-
}),
72-
);
116+
const createRecursiveProxy = <T extends object>(target: T): ToEffect<T> => {
117+
return new Proxy(target as any, {
118+
get(target, prop, receiver) {
119+
const value = Reflect.get(target, prop, receiver);
120+
if (typeof value === "function") {
121+
return Effect.fnUntraced(function* (...args: any[]) {
122+
return yield* Effect.tryPromise({
123+
try: async (signal) => {
124+
let modifiedArgs: any[];
125+
if (isRequestOptions(args[args.length - 1])) {
126+
const options = args[args.length - 1] as RequestOptions;
127+
modifiedArgs = [
128+
...args.slice(0, -1),
129+
{
130+
...options,
131+
signal: options?.signal
132+
? AbortSignal.any([signal, options.signal])
133+
: signal,
134+
},
135+
];
136+
} else {
137+
modifiedArgs = [...args, { signal }];
138+
}
139+
const result = await value.apply(target, modifiedArgs);
140+
return result;
141+
},
142+
catch: (cause) => {
143+
const error = CloudflareApiError.from(cause as APIError);
144+
return error;
145+
},
146+
});
147+
});
148+
}
149+
return createRecursiveProxy(value);
150+
},
151+
});
152+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { ExecutionContext } from "@cloudflare/workers-types";
2+
import * as Context from "effect/Context";
3+
import * as Data from "effect/Data";
4+
import * as Effect from "effect/Effect";
5+
6+
export class CloudflareContext extends Context.Tag("Cloudflare.Context")<
7+
CloudflareContext,
8+
{
9+
env: unknown;
10+
ctx: ExecutionContext;
11+
}
12+
>() {}
13+
14+
export const getCloudflareEnvKey = Effect.fnUntraced(function* <T>(
15+
key: string,
16+
) {
17+
return yield* Effect.serviceOptional(CloudflareContext).pipe(
18+
Effect.mapError(
19+
() =>
20+
new CloudflareContextNotFound({
21+
message: "Cloudflare context not found",
22+
}),
23+
),
24+
Effect.flatMap((context) => {
25+
const env = context.env as Record<string, unknown>;
26+
if (!(key in env)) {
27+
return new CloudflareContextKeyNotFound({
28+
message: `${key} is not set in cloudflare context (found ${Object.keys(env).join(", ")})`,
29+
key,
30+
});
31+
}
32+
return Effect.succeed(env[key] as T);
33+
}),
34+
Effect.orDie,
35+
);
36+
});
37+
38+
export class CloudflareContextNotFound extends Data.TaggedError(
39+
"Cloudflare.Context.NotFound",
40+
)<{
41+
message: string;
42+
}> {}
43+
44+
export class CloudflareContextKeyNotFound extends Data.TaggedError(
45+
"Cloudflare.Context.KeyNotFound",
46+
)<{
47+
message: string;
48+
key: string;
49+
}> {}
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
export * from "./api.ts";
2-
export * from "./kv.provider.ts";
3-
export * from "./kv.ts";
4-
export * from "./worker.ts";
1+
export * as KV from "./kv/index.ts";
2+
export * as R2 from "./r2/index.ts";
3+
export * as Assets from "./worker/assets.fetch.ts";
4+
export * as Worker from "./worker/index.ts";
5+
6+
export type * as Alchemy from "../index.ts";

0 commit comments

Comments
 (0)