effect-cf 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/index.d.mts +1362 -0
- package/dist/index.mjs +1198 -0
- package/package.json +47 -0
- package/src/Binding.ts +70 -0
- package/src/DurableObject.ts +220 -0
- package/src/DurableObjectDefinition.ts +214 -0
- package/src/DurableObjectNamespace.ts +483 -0
- package/src/DurableObjectRpcWebSocket.ts +222 -0
- package/src/DurableObjectState.ts +127 -0
- package/src/DurableObjectStorage.ts +381 -0
- package/src/DurableObjectWebSocket.ts +62 -0
- package/src/Entry.ts +259 -0
- package/src/Environment.ts +86 -0
- package/src/Kv.ts +244 -0
- package/src/Rpc.ts +127 -0
- package/src/RpcDefinition.ts +354 -0
- package/src/ServiceBinding.ts +278 -0
- package/src/Worker.ts +264 -0
- package/src/WorkerDefinition.ts +206 -0
- package/src/index.ts +15 -0
- package/src/internal/Entrypoint.ts +40 -0
- package/src/internal/RpcInvocation.ts +73 -0
package/src/Entry.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { WorkerEntrypoint as CloudflareWorkerEntrypoint } from "cloudflare:workers";
|
|
2
|
+
import { Cause, Context, Effect, Exit, Layer, ManagedRuntime, type Scope } from "effect";
|
|
3
|
+
import { HttpServerRequest } from "effect/unstable/http";
|
|
4
|
+
|
|
5
|
+
import { WorkerEnvironment, type WorkerEnv } from "./Environment";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Access to Cloudflare's native `ExecutionContext`.
|
|
9
|
+
*/
|
|
10
|
+
export class ExecutionContext extends Context.Service<
|
|
11
|
+
ExecutionContext,
|
|
12
|
+
globalThis.ExecutionContext
|
|
13
|
+
>()("effect-cf/ExecutionContext") {}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Options for {@link WorkerContextService.waitUntil}.
|
|
17
|
+
*/
|
|
18
|
+
export interface WorkerContextWaitUntilOptions<E, R> {
|
|
19
|
+
/** Custom failure handler for the background effect. */
|
|
20
|
+
readonly onFailure?: (cause: Cause.Cause<E>) => Effect.Effect<void, never, R>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Effect wrapper around `ExecutionContext` background APIs.
|
|
25
|
+
*/
|
|
26
|
+
export interface WorkerContextService {
|
|
27
|
+
readonly raw: globalThis.ExecutionContext;
|
|
28
|
+
waitUntil<A, E, R, R2 = never>(
|
|
29
|
+
effect: Effect.Effect<A, E, R>,
|
|
30
|
+
options?: WorkerContextWaitUntilOptions<E, R2>,
|
|
31
|
+
): Effect.Effect<void, never, R | R2>;
|
|
32
|
+
readonly passThroughOnException: Effect.Effect<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Service used inside handlers to schedule background work via `waitUntil`.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* const handler = Effect.gen(function* () {
|
|
41
|
+
* const ctx = yield* WorkerContext;
|
|
42
|
+
* yield* ctx.waitUntil(Effect.log("flush analytics"));
|
|
43
|
+
* return new Response("ok");
|
|
44
|
+
* });
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export class WorkerContext extends Context.Service<WorkerContext, WorkerContextService>()(
|
|
48
|
+
"effect-cf/WorkerContext",
|
|
49
|
+
) {}
|
|
50
|
+
|
|
51
|
+
type RunWaitUntilEffect = <A, E>(
|
|
52
|
+
effect: Effect.Effect<A, E, never>,
|
|
53
|
+
) => Promise<Exit.Exit<A, unknown>>;
|
|
54
|
+
|
|
55
|
+
const fromExecutionContext = (
|
|
56
|
+
ctx: globalThis.ExecutionContext,
|
|
57
|
+
runPromiseExit: RunWaitUntilEffect,
|
|
58
|
+
): WorkerContextService => ({
|
|
59
|
+
raw: ctx,
|
|
60
|
+
waitUntil: <A, E, R, R2 = never>(
|
|
61
|
+
effect: Effect.Effect<A, E, R>,
|
|
62
|
+
options?: WorkerContextWaitUntilOptions<E, R2>,
|
|
63
|
+
) =>
|
|
64
|
+
Effect.context<R | R2>().pipe(
|
|
65
|
+
Effect.flatMap((context) =>
|
|
66
|
+
Effect.sync(() => {
|
|
67
|
+
const observed = Effect.exit(effect).pipe(
|
|
68
|
+
Effect.flatMap((exit) => {
|
|
69
|
+
if (Exit.isSuccess(exit)) {
|
|
70
|
+
return Effect.void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const handleFailure =
|
|
74
|
+
options?.onFailure?.(exit.cause) ??
|
|
75
|
+
Effect.logError("WorkerContext.waitUntil failed", Cause.pretty(exit.cause));
|
|
76
|
+
|
|
77
|
+
return handleFailure.pipe(
|
|
78
|
+
Effect.catchCause((handlerCause) =>
|
|
79
|
+
Effect.logError(
|
|
80
|
+
"WorkerContext.waitUntil failure handler failed",
|
|
81
|
+
Cause.pretty(exit.cause),
|
|
82
|
+
Cause.pretty(handlerCause),
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
ctx.waitUntil(
|
|
90
|
+
runPromiseExit(Effect.scoped(Effect.provideContext(observed, context))).then((exit) => {
|
|
91
|
+
if (Exit.isFailure(exit)) {
|
|
92
|
+
console.error(
|
|
93
|
+
"WorkerContext.waitUntil failure handler failed",
|
|
94
|
+
Cause.pretty(exit.cause),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
passThroughOnException: Effect.sync(() => ctx.passThroughOnException()),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Access to the incoming `Request` currently handled by a worker or Durable Object fetch.
|
|
107
|
+
*/
|
|
108
|
+
export class NativeRequest extends Context.Service<NativeRequest, Request>()(
|
|
109
|
+
"effect-cf/NativeRequest",
|
|
110
|
+
) {}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Returns `true` when the request is a websocket upgrade request.
|
|
114
|
+
*/
|
|
115
|
+
export const isWebSocketUpgrade = (request: Request): boolean =>
|
|
116
|
+
request.headers.get("Upgrade")?.toLowerCase() === "websocket";
|
|
117
|
+
|
|
118
|
+
type RequestServices = ExecutionContext | WorkerContext | WorkerEnvironment;
|
|
119
|
+
|
|
120
|
+
type HandlerContext<ROut> =
|
|
121
|
+
| ExecutionContext
|
|
122
|
+
| WorkerContext
|
|
123
|
+
| WorkerEnvironment
|
|
124
|
+
| NativeRequest
|
|
125
|
+
| HttpServerRequest.HttpServerRequest
|
|
126
|
+
| ROut
|
|
127
|
+
| Scope.Scope;
|
|
128
|
+
|
|
129
|
+
type RuntimeContext<ROut> = RequestServices | ROut;
|
|
130
|
+
type RpcContext<ROut> = RuntimeContext<ROut> | Scope.Scope;
|
|
131
|
+
|
|
132
|
+
const RunSymbol = Symbol.for("effect-cf/Worker/run");
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Effect type for `fetch` handlers executed by {@link make}.
|
|
136
|
+
*/
|
|
137
|
+
export type WorkerHandler<ROut, A = unknown> = Effect.Effect<A, unknown, HandlerContext<ROut>>;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Effect type for worker RPC handlers.
|
|
141
|
+
*/
|
|
142
|
+
export type WorkerRpcHandler<ROut, A = unknown> = Effect.Effect<A, unknown, RpcContext<ROut>>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Shape of worker RPC handlers passed to {@link make}.
|
|
146
|
+
*/
|
|
147
|
+
export type WorkerRpc<ROut> = Record<string, (...args: Array<any>) => WorkerRpcHandler<ROut>>;
|
|
148
|
+
|
|
149
|
+
export type WorkerRpcShape<Rpc extends WorkerRpc<ROut>, ROut> = {
|
|
150
|
+
readonly [Key in keyof Rpc]: Rpc[Key] extends (
|
|
151
|
+
...args: infer Args
|
|
152
|
+
) => Effect.Effect<infer A, unknown, RpcContext<ROut>>
|
|
153
|
+
? (...args: Args) => Promise<A>
|
|
154
|
+
: never;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type RpcHandlers<ROut, Api> = {
|
|
158
|
+
readonly [Key in keyof Api as Key extends keyof CloudflareWorkerEntrypoint
|
|
159
|
+
? never
|
|
160
|
+
: Key extends string
|
|
161
|
+
? [Api[Key]] extends [never]
|
|
162
|
+
? never
|
|
163
|
+
: Api[Key] extends (...args: Array<any>) => Promise<unknown>
|
|
164
|
+
? Key
|
|
165
|
+
: never
|
|
166
|
+
: never]: Api[Key] extends (...args: infer Args) => Promise<infer A>
|
|
167
|
+
? (...args: Args) => WorkerRpcHandler<ROut, A>
|
|
168
|
+
: never;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Options for creating a worker class with Effect handlers.
|
|
173
|
+
*/
|
|
174
|
+
export interface WorkerOptions<ROut, Rpc extends WorkerRpc<ROut>> {
|
|
175
|
+
/** Main request handler. */
|
|
176
|
+
readonly fetch: Effect.Effect<Response, unknown, HandlerContext<ROut>>;
|
|
177
|
+
/** Optional RPC methods exposed as class instance methods. */
|
|
178
|
+
readonly rpc?: Rpc;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Cloudflare `WorkerEntrypoint` constructor produced by {@link make}.
|
|
183
|
+
*/
|
|
184
|
+
export type WorkerClass<Rpc extends WorkerRpc<ROut>, ROut> = new (
|
|
185
|
+
ctx: globalThis.ExecutionContext,
|
|
186
|
+
env: WorkerEnv,
|
|
187
|
+
) => CloudflareWorkerEntrypoint<WorkerEnv> & WorkerRpcShape<Rpc, ROut>;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Creates a Cloudflare worker class backed by a single managed Effect runtime.
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```ts
|
|
194
|
+
* const Worker = Entry.make(Layer.empty, {
|
|
195
|
+
* fetch: Effect.succeed(new Response("ok")),
|
|
196
|
+
* });
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
export const make = <ROut, LayerError, const Rpc extends WorkerRpc<ROut> = Record<never, never>>(
|
|
200
|
+
layer: Layer.Layer<ROut, LayerError, RequestServices>,
|
|
201
|
+
options: WorkerOptions<ROut, Rpc>,
|
|
202
|
+
): WorkerClass<Rpc, ROut> => {
|
|
203
|
+
class EffectWorker extends CloudflareWorkerEntrypoint<WorkerEnv> {
|
|
204
|
+
readonly runtime: ManagedRuntime.ManagedRuntime<RuntimeContext<ROut>, LayerError>;
|
|
205
|
+
|
|
206
|
+
constructor(ctx: globalThis.ExecutionContext, env: WorkerEnv) {
|
|
207
|
+
super(ctx, env);
|
|
208
|
+
|
|
209
|
+
let runWaitUntilEffect: RunWaitUntilEffect = () =>
|
|
210
|
+
Promise.resolve(Exit.die(new Error("WorkerContext runtime is not initialized")));
|
|
211
|
+
|
|
212
|
+
const services = Layer.mergeAll(
|
|
213
|
+
Layer.succeed(ExecutionContext, ctx),
|
|
214
|
+
Layer.succeed(
|
|
215
|
+
WorkerContext,
|
|
216
|
+
fromExecutionContext(ctx, (effect) => runWaitUntilEffect(effect)),
|
|
217
|
+
),
|
|
218
|
+
Layer.succeed(WorkerEnvironment, env),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const runtimeLayer = layer.pipe(Layer.provideMerge(services)) as Layer.Layer<
|
|
222
|
+
RuntimeContext<ROut>,
|
|
223
|
+
LayerError,
|
|
224
|
+
never
|
|
225
|
+
>;
|
|
226
|
+
|
|
227
|
+
this.runtime = ManagedRuntime.make(runtimeLayer);
|
|
228
|
+
runWaitUntilEffect = <A, E>(effect: Effect.Effect<A, E, never>) =>
|
|
229
|
+
this.runtime.runPromiseExit(effect as Effect.Effect<A, E, RuntimeContext<ROut>>);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
[RunSymbol]<A, E>(effect: Effect.Effect<A, E, RuntimeContext<ROut> | Scope.Scope>): Promise<A> {
|
|
233
|
+
return this.runtime.runPromise(Effect.scoped(effect));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
fetch(request: Request): Promise<Response> {
|
|
237
|
+
return this[RunSymbol](
|
|
238
|
+
options.fetch.pipe(
|
|
239
|
+
Effect.provideService(NativeRequest, request),
|
|
240
|
+
Effect.provideService(
|
|
241
|
+
HttpServerRequest.HttpServerRequest,
|
|
242
|
+
HttpServerRequest.fromWeb(request),
|
|
243
|
+
),
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const [key, method] of Object.entries(options.rpc ?? {})) {
|
|
250
|
+
Object.defineProperty(EffectWorker.prototype, key, {
|
|
251
|
+
enumerable: true,
|
|
252
|
+
value(this: EffectWorker, ...args: Array<any>) {
|
|
253
|
+
return this[RunSymbol](Effect.suspend(() => method(...args)));
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return EffectWorker as WorkerClass<Rpc, ROut>;
|
|
259
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Config, ConfigProvider, Context, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Cloudflare worker environment object (`env`). */
|
|
4
|
+
export type WorkerEnv = Cloudflare.Env;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Context service for reading worker bindings from the current `env` object.
|
|
8
|
+
*/
|
|
9
|
+
export class WorkerEnvironment extends Context.Service<WorkerEnvironment, WorkerEnv>()(
|
|
10
|
+
"effect-cf/WorkerEnvironment",
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
type ScalarConfigValue = string | number | boolean;
|
|
14
|
+
|
|
15
|
+
type ScalarConfigKey = Extract<
|
|
16
|
+
{
|
|
17
|
+
readonly [Key in keyof Cloudflare.Env]-?: NonNullable<
|
|
18
|
+
Cloudflare.Env[Key]
|
|
19
|
+
> extends ScalarConfigValue
|
|
20
|
+
? Key
|
|
21
|
+
: never;
|
|
22
|
+
}[keyof Cloudflare.Env],
|
|
23
|
+
string
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Effect `Config` helpers for scalar Cloudflare vars and secrets declared on
|
|
28
|
+
* `Cloudflare.Env` / generated `worker-configuration.d.ts`.
|
|
29
|
+
*
|
|
30
|
+
* Users still author their app config explicitly with Effect `Config`:
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* import { Config, Effect } from "effect";
|
|
34
|
+
* import { WorkerConfig } from "effect-cf";
|
|
35
|
+
*
|
|
36
|
+
* const AppConfig = Config.all({
|
|
37
|
+
* databaseUrl: WorkerConfig.redacted("DATABASE_URL"),
|
|
38
|
+
* port: WorkerConfig.integer("PORT").pipe(Config.withDefault(8787)),
|
|
39
|
+
* });
|
|
40
|
+
*
|
|
41
|
+
* const program = Effect.gen(function* () {
|
|
42
|
+
* const config = yield* AppConfig;
|
|
43
|
+
* // ...
|
|
44
|
+
* }).pipe(Effect.provide(WorkerConfig.layer));
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* Keys are constrained to scalar `Cloudflare.Env` properties (`string`,
|
|
48
|
+
* `number`, or `boolean`, including optional scalar properties). Binding
|
|
49
|
+
* objects such as `KVNamespace`, `DurableObjectNamespace`, and service
|
|
50
|
+
* bindings are intentionally excluded; keep using the package binding helpers
|
|
51
|
+
* for those live resources.
|
|
52
|
+
*/
|
|
53
|
+
export namespace WorkerConfig {
|
|
54
|
+
/** Scalar env value types supported by the typed config key helpers. */
|
|
55
|
+
export type Scalar = ScalarConfigValue;
|
|
56
|
+
|
|
57
|
+
/** Scalar keys available on the current consumer's `Cloudflare.Env`. */
|
|
58
|
+
export type Key = ScalarConfigKey;
|
|
59
|
+
|
|
60
|
+
/** Read a scalar Cloudflare var or secret as a string. */
|
|
61
|
+
export const string = <const Name extends Key>(name: Name) => Config.string(name);
|
|
62
|
+
|
|
63
|
+
/** Read a scalar Cloudflare secret as a redacted string. */
|
|
64
|
+
export const redacted = <const Name extends Key>(name: Name) => Config.redacted(name);
|
|
65
|
+
|
|
66
|
+
/** Read a scalar Cloudflare var or secret as a number. */
|
|
67
|
+
export const number = <const Name extends Key>(name: Name) => Config.number(name);
|
|
68
|
+
|
|
69
|
+
/** Read a scalar Cloudflare var or secret as an integer. */
|
|
70
|
+
export const integer = <const Name extends Key>(name: Name) => Config.int(name);
|
|
71
|
+
|
|
72
|
+
/** Read a scalar Cloudflare var or secret as a boolean. */
|
|
73
|
+
export const boolean = <const Name extends Key>(name: Name) => Config.boolean(name);
|
|
74
|
+
|
|
75
|
+
/** Build a `ConfigProvider` from the current `WorkerEnvironment`. */
|
|
76
|
+
export const provider = Effect.map(WorkerEnvironment.asEffect(), ConfigProvider.fromUnknown);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Replace the active Effect `ConfigProvider` with one backed by the current
|
|
80
|
+
* Cloudflare worker `env` object.
|
|
81
|
+
*/
|
|
82
|
+
export const providerLayer = ConfigProvider.layer(provider);
|
|
83
|
+
|
|
84
|
+
/** Alias for `providerLayer` for concise use in worker layers. */
|
|
85
|
+
export const layer = providerLayer;
|
|
86
|
+
}
|
package/src/Kv.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { Data, Effect, Option, Schema as S } from "effect";
|
|
2
|
+
|
|
3
|
+
import * as Binding from "./Binding";
|
|
4
|
+
|
|
5
|
+
const TypeId = "effect-cf/Kv" as const;
|
|
6
|
+
|
|
7
|
+
/** Error raised when a KV operation fails. */
|
|
8
|
+
export class KvOperationError extends Data.TaggedError("KvOperationError")<{
|
|
9
|
+
readonly binding: string;
|
|
10
|
+
readonly operation: string;
|
|
11
|
+
readonly cause: unknown;
|
|
12
|
+
}> {}
|
|
13
|
+
|
|
14
|
+
/** `KVNamespace.put` options. */
|
|
15
|
+
export type KvPutOptions = globalThis.KVNamespacePutOptions;
|
|
16
|
+
/** `KVNamespace.list` options with optional metadata decoding schema. */
|
|
17
|
+
export type KvListOptions<Metadata = unknown> = globalThis.KVNamespaceListOptions & {
|
|
18
|
+
readonly metadataSchema?: S.Codec<Metadata, unknown>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Successful value returned by `getWithMetadata`. */
|
|
22
|
+
export interface KvWithMetadata<Value, Metadata> {
|
|
23
|
+
readonly value: Value;
|
|
24
|
+
readonly metadata: Option.Option<Metadata>;
|
|
25
|
+
readonly cacheStatus: Option.Option<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Decoded key entry returned by `list`. */
|
|
29
|
+
export interface KvListKey<Key, Metadata = unknown> {
|
|
30
|
+
readonly name: Key;
|
|
31
|
+
readonly expiration: Option.Option<number>;
|
|
32
|
+
readonly metadata: Option.Option<Metadata>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Decoded result returned by `list`. */
|
|
36
|
+
export interface KvListResult<Key, Metadata = unknown> {
|
|
37
|
+
readonly keys: ReadonlyArray<KvListKey<Key, Metadata>>;
|
|
38
|
+
readonly listComplete: boolean;
|
|
39
|
+
readonly cursor: Option.Option<string>;
|
|
40
|
+
readonly cacheStatus: Option.Option<string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Typed KV binding definition.
|
|
45
|
+
*/
|
|
46
|
+
export interface KvDefinition<Key, Value, EncodedValue> {
|
|
47
|
+
/** Binding name as configured in `wrangler.jsonc`. */
|
|
48
|
+
readonly binding: string;
|
|
49
|
+
/** Codec used to encode/decode keys. */
|
|
50
|
+
readonly key: S.Codec<Key, string>;
|
|
51
|
+
/** Codec used to encode/decode values. */
|
|
52
|
+
readonly value: S.Codec<Value, EncodedValue>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
declare const KvServiceTypeId: unique symbol;
|
|
56
|
+
|
|
57
|
+
/** Nominal service marker for KV services created with {@link make}. */
|
|
58
|
+
export interface KvService<Id extends string, Key, Value, EncodedValue> {
|
|
59
|
+
readonly [KvServiceTypeId]: {
|
|
60
|
+
readonly id: Id;
|
|
61
|
+
readonly key: Key;
|
|
62
|
+
readonly value: Value;
|
|
63
|
+
readonly encodedValue: EncodedValue;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const maybeString = (value: string | null | undefined): Option.Option<string> =>
|
|
68
|
+
value == null ? Option.none() : Option.some(value);
|
|
69
|
+
|
|
70
|
+
const maybeNumber = (value: number | undefined): Option.Option<number> =>
|
|
71
|
+
value === undefined ? Option.none() : Option.some(value);
|
|
72
|
+
|
|
73
|
+
const kvError = (binding: string, operation: string, cause: unknown) =>
|
|
74
|
+
new KvOperationError({ binding, operation, cause });
|
|
75
|
+
|
|
76
|
+
const tryKvPromise = <A>(
|
|
77
|
+
binding: string,
|
|
78
|
+
operation: string,
|
|
79
|
+
evaluate: () => Promise<A>,
|
|
80
|
+
): Effect.Effect<A, KvOperationError> =>
|
|
81
|
+
Effect.tryPromise({
|
|
82
|
+
try: evaluate,
|
|
83
|
+
catch: (cause) => kvError(binding, operation, cause),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Creates a typed KV service tag plus Effect helpers.
|
|
88
|
+
*/
|
|
89
|
+
export const make = <Id extends string, Key, Value, EncodedValue>(
|
|
90
|
+
id: Id,
|
|
91
|
+
definition: KvDefinition<Key, Value, EncodedValue>,
|
|
92
|
+
) =>
|
|
93
|
+
Service<KvService<Id, Key, Value, EncodedValue>>()<Id, Key, Value, EncodedValue>(id, definition);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Builds a KV service around a Cloudflare KV namespace binding.
|
|
97
|
+
*
|
|
98
|
+
* Returned service includes schema-aware `put`, `get`, `getWithMetadata`,
|
|
99
|
+
* `list`, and `remove` helpers.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* const Sessions = Kv.make("Sessions", {
|
|
104
|
+
* binding: "SESSIONS",
|
|
105
|
+
* key: Schema.String,
|
|
106
|
+
* value: Schema.Struct({ userId: Schema.String }),
|
|
107
|
+
* });
|
|
108
|
+
*
|
|
109
|
+
* const program = Effect.gen(function* () {
|
|
110
|
+
* yield* Sessions.put("abc", { userId: "u_1" });
|
|
111
|
+
* return yield* Sessions.get("abc");
|
|
112
|
+
* });
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export const Service =
|
|
116
|
+
<Self>() =>
|
|
117
|
+
<Id extends string, Key, Value, EncodedValue>(
|
|
118
|
+
id: Id,
|
|
119
|
+
definition: KvDefinition<Key, Value, EncodedValue>,
|
|
120
|
+
) => {
|
|
121
|
+
const tag = Binding.Service<Self>()(id, definition.binding, (value): value is KVNamespace => {
|
|
122
|
+
if (typeof value !== "object" || value === null) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const resource = value as Record<string, unknown>;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
typeof resource.get === "function" &&
|
|
130
|
+
typeof resource.put === "function" &&
|
|
131
|
+
typeof resource.delete === "function" &&
|
|
132
|
+
typeof resource.getWithMetadata === "function" &&
|
|
133
|
+
typeof resource.list === "function"
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const encodeKey = S.encodeEffect(definition.key);
|
|
138
|
+
const decodeKey = S.decodeUnknownEffect(definition.key);
|
|
139
|
+
const encodeValue = S.encodeEffect(S.fromJsonString(S.toCodecJson(definition.value)));
|
|
140
|
+
const decodeValue = S.decodeUnknownEffect(S.fromJsonString(S.toCodecJson(definition.value)));
|
|
141
|
+
|
|
142
|
+
const put = Effect.fnUntraced(function* (key: Key, value: Value, options?: KvPutOptions) {
|
|
143
|
+
const kv = yield* tag;
|
|
144
|
+
const keyEncoded = yield* encodeKey(key);
|
|
145
|
+
const valueEncoded = yield* encodeValue(value);
|
|
146
|
+
yield* tryKvPromise(definition.binding, "put", () =>
|
|
147
|
+
kv.put(keyEncoded, valueEncoded, options),
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const get = Effect.fnUntraced(function* (key: Key) {
|
|
152
|
+
const kv = yield* tag;
|
|
153
|
+
const keyEncoded = yield* encodeKey(key);
|
|
154
|
+
const valueEncoded = yield* tryKvPromise(definition.binding, "get", () => kv.get(keyEncoded));
|
|
155
|
+
|
|
156
|
+
if (valueEncoded === null) {
|
|
157
|
+
return Option.none();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return yield* decodeValue(valueEncoded).pipe(Effect.map(Option.some));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const getWithMetadata = Effect.fnUntraced(function* <Metadata>(
|
|
164
|
+
key: Key,
|
|
165
|
+
metadataSchema: S.Codec<Metadata, unknown>,
|
|
166
|
+
) {
|
|
167
|
+
const kv = yield* tag;
|
|
168
|
+
const keyEncoded = yield* encodeKey(key);
|
|
169
|
+
const result = yield* tryKvPromise(definition.binding, "getWithMetadata", () =>
|
|
170
|
+
kv.getWithMetadata<Metadata>(keyEncoded),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (result.value === null) {
|
|
174
|
+
return Option.none();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const value = yield* decodeValue(result.value);
|
|
178
|
+
const metadata =
|
|
179
|
+
result.metadata === null
|
|
180
|
+
? Option.none<Metadata>()
|
|
181
|
+
: Option.some(yield* S.decodeUnknownEffect(metadataSchema)(result.metadata));
|
|
182
|
+
|
|
183
|
+
return Option.some({
|
|
184
|
+
value,
|
|
185
|
+
metadata,
|
|
186
|
+
cacheStatus: maybeString(result.cacheStatus),
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const list = Effect.fnUntraced(function* <Metadata = unknown>(
|
|
191
|
+
options?: KvListOptions<Metadata>,
|
|
192
|
+
) {
|
|
193
|
+
const kv = yield* tag;
|
|
194
|
+
const { metadataSchema, ...kvOptions } = options ?? {};
|
|
195
|
+
const result = yield* tryKvPromise(definition.binding, "list", () =>
|
|
196
|
+
kv.list<Metadata>(kvOptions),
|
|
197
|
+
);
|
|
198
|
+
const keys: Array<KvListKey<Key, Metadata>> = [];
|
|
199
|
+
|
|
200
|
+
for (const key of result.keys) {
|
|
201
|
+
const decodedName = yield* decodeKey(key.name);
|
|
202
|
+
const decodedMetadata =
|
|
203
|
+
key.metadata === undefined
|
|
204
|
+
? Option.none<Metadata>()
|
|
205
|
+
: metadataSchema === undefined
|
|
206
|
+
? Option.some(key.metadata as Metadata)
|
|
207
|
+
: Option.some(yield* S.decodeUnknownEffect(metadataSchema)(key.metadata));
|
|
208
|
+
|
|
209
|
+
keys.push({
|
|
210
|
+
name: decodedName,
|
|
211
|
+
expiration: maybeNumber(key.expiration),
|
|
212
|
+
metadata: decodedMetadata,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
keys,
|
|
218
|
+
listComplete: result.list_complete,
|
|
219
|
+
cursor: maybeString("cursor" in result ? result.cursor : undefined),
|
|
220
|
+
cacheStatus: maybeString(result.cacheStatus),
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const remove = Effect.fnUntraced(function* (key: Key) {
|
|
225
|
+
const kv = yield* tag;
|
|
226
|
+
const keyEncoded = yield* encodeKey(key);
|
|
227
|
+
yield* tryKvPromise(definition.binding, "delete", () => kv.delete(keyEncoded));
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const unsafeRaw = Effect.fnUntraced(function* () {
|
|
231
|
+
return yield* tag;
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return Object.assign(tag, {
|
|
235
|
+
[TypeId]: TypeId,
|
|
236
|
+
definition,
|
|
237
|
+
put,
|
|
238
|
+
get,
|
|
239
|
+
getWithMetadata,
|
|
240
|
+
list,
|
|
241
|
+
remove,
|
|
242
|
+
unsafeRaw,
|
|
243
|
+
});
|
|
244
|
+
};
|