@alchemy.run/redis 0.0.0 → 2.0.0-beta.84

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.
@@ -0,0 +1,403 @@
1
+ import type { RuntimeContext } from "alchemy/RuntimeContext";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Queue from "effect/Queue";
4
+ import * as Scope from "effect/Scope";
5
+ import * as Semaphore from "effect/Semaphore";
6
+ import { CommandError, ProtocolError, type UrlMissing } from "./Errors.ts";
7
+ import { encodeCommand, Parser, type Arg, type Reply } from "./Resp.ts";
8
+
9
+ export const DEFAULT_PORT = 6379;
10
+ export const TLS_PORT = 6380;
11
+
12
+ type SocketEvent =
13
+ | { readonly _tag: "Data"; readonly bytes: Uint8Array }
14
+ | { readonly _tag: "End" }
15
+ | { readonly _tag: "Fail"; readonly cause: unknown };
16
+
17
+ interface RawSocket {
18
+ write(bytes: Uint8Array): number | Promise<number>;
19
+ end(): void;
20
+ }
21
+
22
+ export interface RedisUrl {
23
+ readonly hostname: string;
24
+ readonly port: number;
25
+ readonly tls: boolean;
26
+ readonly username: string;
27
+ readonly password: string;
28
+ readonly db: number | undefined;
29
+ }
30
+
31
+ /** Parse a `redis://` or `rediss://` URL. */
32
+ export const parseUrl = (url: string): RedisUrl => {
33
+ const parsed = new URL(url);
34
+ const tls = parsed.protocol === "rediss:";
35
+ const path = parsed.pathname.startsWith("/") ? parsed.pathname.slice(1) : parsed.pathname;
36
+ const dbRaw = path.length === 0 ? undefined : Number(path);
37
+ return {
38
+ hostname: parsed.hostname,
39
+ port: Number(parsed.port || (tls ? TLS_PORT : DEFAULT_PORT)),
40
+ tls,
41
+ username: decodeURIComponent(parsed.username),
42
+ password: decodeURIComponent(parsed.password),
43
+ db: dbRaw !== undefined && Number.isInteger(dbRaw) && dbRaw >= 0 ? dbRaw : undefined,
44
+ };
45
+ };
46
+
47
+ /** Build a `redis://` URL. Encodes the password. Used by tests and TcpProxy. */
48
+ export const connectionUrl = (input: {
49
+ host: string;
50
+ port: number;
51
+ password: string;
52
+ username?: string;
53
+ db?: number;
54
+ tls?: boolean;
55
+ }): string => {
56
+ const username = input.username ?? "default";
57
+ const scheme = input.tls ? "rediss" : "redis";
58
+ const db = input.db !== undefined ? `/${input.db}` : "";
59
+ return `${scheme}://${username}:${encodeURIComponent(input.password)}@${input.host}:${input.port}${db}`;
60
+ };
61
+
62
+ const commandError = (command: string, cause: unknown): CommandError =>
63
+ cause instanceof CommandError
64
+ ? new CommandError({ command, cause: cause.cause })
65
+ : new CommandError({ command, cause });
66
+
67
+ const asBytes = (data: Uint8Array): Uint8Array => new Uint8Array(data);
68
+
69
+ const writeSocket = (socket: RawSocket, bytes: Uint8Array): Effect.Effect<void, CommandError> => {
70
+ const written = socket.write(bytes);
71
+ if (typeof written === "object" && written !== null && "then" in written) {
72
+ return Effect.tryPromise({
73
+ try: () => Promise.resolve(written),
74
+ catch: (cause) => commandError("WRITE", cause),
75
+ }).pipe(Effect.asVoid);
76
+ }
77
+ return Effect.void;
78
+ };
79
+
80
+ const openBun = (
81
+ options: RedisUrl,
82
+ events: Queue.Queue<SocketEvent>,
83
+ ): Effect.Effect<RawSocket, CommandError> =>
84
+ Effect.callback<RawSocket, CommandError>((resume, signal) => {
85
+ let settled = false;
86
+ let socket: RawSocket | undefined;
87
+ const succeed = (value: RawSocket) => {
88
+ if (settled) return;
89
+ settled = true;
90
+ resume(Effect.succeed(value));
91
+ };
92
+ const fail = (cause: unknown) => {
93
+ if (settled) {
94
+ Queue.offerUnsafe(events, { _tag: "Fail", cause });
95
+ return;
96
+ }
97
+ settled = true;
98
+ resume(Effect.fail(commandError("CONNECT", cause)));
99
+ };
100
+ const Client = (globalThis as { Bun?: { connect: typeof Bun.connect } }).Bun;
101
+ if (Client === undefined) {
102
+ fail(new Error("Bun.connect is not available"));
103
+ return;
104
+ }
105
+ Client.connect({
106
+ hostname: options.hostname,
107
+ port: options.port,
108
+ tls: options.tls,
109
+ socket: {
110
+ binaryType: "uint8array",
111
+ open: (opened) => {
112
+ socket = opened;
113
+ succeed(opened);
114
+ },
115
+ data: (_opened, chunk) => {
116
+ Queue.offerUnsafe(events, {
117
+ _tag: "Data",
118
+ bytes: asBytes(chunk),
119
+ });
120
+ },
121
+ error: (_opened, cause) => fail(cause),
122
+ connectError: (_opened, cause) => fail(cause),
123
+ close: () => {
124
+ Queue.offerUnsafe(events, { _tag: "End" });
125
+ },
126
+ end: () => {
127
+ Queue.offerUnsafe(events, { _tag: "End" });
128
+ },
129
+ },
130
+ }).catch(fail);
131
+
132
+ const abort = () => {
133
+ socket?.end();
134
+ };
135
+ signal.addEventListener("abort", abort, { once: true });
136
+ return Effect.sync(() => {
137
+ signal.removeEventListener("abort", abort);
138
+ abort();
139
+ });
140
+ });
141
+
142
+ const openNode = (
143
+ options: RedisUrl,
144
+ events: Queue.Queue<SocketEvent>,
145
+ ): Effect.Effect<RawSocket, CommandError> =>
146
+ Effect.callback<RawSocket, CommandError>((resume, signal) => {
147
+ let settled = false;
148
+ let socket: { write(bytes: Uint8Array): boolean; end(): void } | undefined;
149
+ const succeed = (value: RawSocket) => {
150
+ if (settled) return;
151
+ settled = true;
152
+ resume(Effect.succeed(value));
153
+ };
154
+ const fail = (cause: unknown) => {
155
+ if (settled) {
156
+ Queue.offerUnsafe(events, { _tag: "Fail", cause });
157
+ return;
158
+ }
159
+ settled = true;
160
+ resume(Effect.fail(commandError("CONNECT", cause)));
161
+ };
162
+
163
+ const attach = (
164
+ nodeSocket: {
165
+ write(bytes: Uint8Array): boolean;
166
+ end(): void;
167
+ on(event: string, listener: (...args: any[]) => void): unknown;
168
+ },
169
+ ready: "connect" | "secureConnect",
170
+ ) => {
171
+ socket = nodeSocket;
172
+ nodeSocket.on(ready, () =>
173
+ succeed({
174
+ write: (bytes) => {
175
+ nodeSocket.write(bytes);
176
+ return bytes.length;
177
+ },
178
+ end: () => {
179
+ nodeSocket.end();
180
+ },
181
+ }),
182
+ );
183
+ nodeSocket.on("data", (chunk: Uint8Array) => {
184
+ Queue.offerUnsafe(events, { _tag: "Data", bytes: asBytes(chunk) });
185
+ });
186
+ nodeSocket.on("error", fail);
187
+ nodeSocket.on("close", () => {
188
+ Queue.offerUnsafe(events, { _tag: "End" });
189
+ });
190
+ };
191
+
192
+ const start = options.tls
193
+ ? import("node:tls").then((tls) =>
194
+ attach(
195
+ tls.connect({
196
+ host: options.hostname,
197
+ port: options.port,
198
+ servername: options.hostname,
199
+ }),
200
+ "secureConnect",
201
+ ),
202
+ )
203
+ : import("node:net").then((net) =>
204
+ attach(net.connect({ host: options.hostname, port: options.port }), "connect"),
205
+ );
206
+ start.catch(fail);
207
+
208
+ const abort = () => {
209
+ socket?.end();
210
+ };
211
+ signal.addEventListener("abort", abort, { once: true });
212
+ return Effect.sync(() => {
213
+ signal.removeEventListener("abort", abort);
214
+ abort();
215
+ });
216
+ });
217
+
218
+ const openRaw = (
219
+ options: RedisUrl,
220
+ events: Queue.Queue<SocketEvent>,
221
+ ): Effect.Effect<RawSocket, CommandError> =>
222
+ typeof Bun !== "undefined" ? openBun(options, events) : openNode(options, events);
223
+
224
+ const unwrap = (
225
+ frame: Exclude<import("./Resp.ts").ParseResult, { _tag: "Incomplete" }>,
226
+ command: string,
227
+ ): Effect.Effect<Reply, CommandError> => {
228
+ if (frame._tag === "Protocol") {
229
+ return Effect.fail(commandError(command, frame.error));
230
+ }
231
+ if (frame._tag === "Error") {
232
+ return Effect.fail(commandError(command, frame.error));
233
+ }
234
+ if (frame._tag === "Push") {
235
+ return Effect.succeed(frame.value);
236
+ }
237
+ return Effect.succeed(frame.value);
238
+ };
239
+
240
+ const expectStatus = (reply: Reply, command: string): Effect.Effect<void, CommandError> => {
241
+ if (typeof reply === "string" && reply.toUpperCase() === "OK") {
242
+ return Effect.void;
243
+ }
244
+ return Effect.fail(
245
+ commandError(command, new ProtocolError({ message: `expected OK, got ${String(reply)}` })),
246
+ );
247
+ };
248
+
249
+ export interface Connection {
250
+ readonly send: (command: string, args?: readonly Arg[]) => Effect.Effect<Reply, CommandError>;
251
+ readonly pipeline: (
252
+ commands: ReadonlyArray<readonly [string, ...Arg[]]>,
253
+ ) => Effect.Effect<readonly Reply[], CommandError>;
254
+ }
255
+
256
+ const readReplies = (
257
+ events: Queue.Queue<SocketEvent>,
258
+ parser: Parser,
259
+ count: number,
260
+ command: string,
261
+ ): Effect.Effect<Reply[], CommandError> =>
262
+ Effect.gen(function* () {
263
+ const replies: Reply[] = [];
264
+ while (replies.length < count) {
265
+ const event = yield* Queue.take(events);
266
+ if (event._tag === "Fail") {
267
+ return yield* commandError(command, event.cause);
268
+ }
269
+ if (event._tag === "End") {
270
+ return yield* commandError(command, new ProtocolError({ message: "connection closed" }));
271
+ }
272
+ parser.push(event.bytes);
273
+ while (replies.length < count) {
274
+ const frame = parser.next();
275
+ if (frame._tag === "Incomplete") break;
276
+ if (frame._tag === "Push") continue;
277
+ replies.push(yield* unwrap(frame, command));
278
+ }
279
+ }
280
+ return replies;
281
+ });
282
+
283
+ /**
284
+ * Open an authenticated Redis session. Closes when the ambient Scope
285
+ * is released. Concurrent `send`/`pipeline` calls on one connection
286
+ * are serialized so replies stay ordered.
287
+ */
288
+ export const connect = (url: string): Effect.Effect<Connection, CommandError, Scope.Scope> =>
289
+ Effect.gen(function* () {
290
+ const options = yield* Effect.try({
291
+ try: () => parseUrl(url),
292
+ catch: (cause) => commandError("CONNECT", cause),
293
+ });
294
+ const events = yield* Queue.unbounded<SocketEvent>();
295
+ const socket = yield* Effect.acquireRelease(openRaw(options, events), (opened) =>
296
+ Effect.sync(() => {
297
+ opened.end();
298
+ }).pipe(
299
+ Effect.flatMap(() => Queue.shutdown(events)),
300
+ Effect.asVoid,
301
+ ),
302
+ );
303
+ const parser = new Parser();
304
+ const lock = yield* Semaphore.make(1);
305
+
306
+ const request = (payload: Uint8Array, count: number, command: string) =>
307
+ lock.withPermit(
308
+ Effect.gen(function* () {
309
+ yield* writeSocket(socket, payload);
310
+ return yield* readReplies(events, parser, count, command);
311
+ }),
312
+ );
313
+
314
+ if (options.password.length > 0) {
315
+ const authArgs =
316
+ options.username.length > 0 && options.username !== "default"
317
+ ? [options.username, options.password]
318
+ : [options.password];
319
+ const replies = yield* request(encodeCommand("AUTH", authArgs), 1, "AUTH");
320
+ yield* expectStatus(replies[0] ?? null, "AUTH");
321
+ }
322
+
323
+ if (options.db !== undefined && options.db !== 0) {
324
+ const replies = yield* request(encodeCommand("SELECT", [options.db]), 1, "SELECT");
325
+ yield* expectStatus(replies[0] ?? null, "SELECT");
326
+ }
327
+
328
+ return {
329
+ send: (command, args = []) =>
330
+ request(encodeCommand(command, args), 1, command).pipe(
331
+ Effect.map((replies) => replies[0] ?? null),
332
+ ),
333
+ pipeline: (commands) => {
334
+ if (commands.length === 0) return Effect.succeed([]);
335
+ const chunks = commands.map(([name, ...args]) => encodeCommand(name, args));
336
+ let total = 0;
337
+ for (const chunk of chunks) total += chunk.length;
338
+ const payload = new Uint8Array(total);
339
+ let offset = 0;
340
+ for (const chunk of chunks) {
341
+ payload.set(chunk, offset);
342
+ offset += chunk.length;
343
+ }
344
+ return request(payload, commands.length, commands[0]?.[0] ?? "PIPELINE");
345
+ },
346
+ } satisfies Connection;
347
+ });
348
+
349
+ const scoped = <A>(
350
+ command: string,
351
+ effect: Effect.Effect<A, CommandError, Scope.Scope>,
352
+ ): Effect.Effect<A, CommandError> =>
353
+ Effect.scoped(effect).pipe(Effect.mapError((error) => commandError(command, error)));
354
+
355
+ /**
356
+ * Send one Redis command over RESP and close the connection.
357
+ */
358
+ export const run = (
359
+ url: string,
360
+ command: string,
361
+ args: readonly Arg[] = [],
362
+ ): Effect.Effect<Reply, CommandError> =>
363
+ scoped(
364
+ command,
365
+ Effect.gen(function* () {
366
+ const connection = yield* connect(url);
367
+ return yield* connection.send(command, args);
368
+ }),
369
+ );
370
+
371
+ /**
372
+ * Pipeline several commands on one connection, then close it.
373
+ */
374
+ export const pipeline = (
375
+ url: string,
376
+ commands: ReadonlyArray<readonly [string, ...Arg[]]>,
377
+ ): Effect.Effect<readonly Reply[], CommandError> =>
378
+ scoped(
379
+ commands[0]?.[0] ?? "PIPELINE",
380
+ Effect.gen(function* () {
381
+ const connection = yield* connect(url);
382
+ return yield* connection.pipeline(commands);
383
+ }),
384
+ );
385
+
386
+ export const command = (
387
+ url: Effect.Effect<string, UrlMissing, RuntimeContext>,
388
+ name: string,
389
+ args: readonly Arg[] = [],
390
+ ): Effect.Effect<Reply, CommandError | UrlMissing, RuntimeContext> =>
391
+ Effect.gen(function* () {
392
+ const resolved = yield* url;
393
+ return yield* run(resolved, name, args);
394
+ });
395
+
396
+ export const commandPipeline = (
397
+ url: Effect.Effect<string, UrlMissing, RuntimeContext>,
398
+ commands: ReadonlyArray<readonly [string, ...Arg[]]>,
399
+ ): Effect.Effect<readonly Reply[], CommandError | UrlMissing, RuntimeContext> =>
400
+ Effect.gen(function* () {
401
+ const resolved = yield* url;
402
+ return yield* pipeline(resolved, commands);
403
+ });