@alchemy.run/redis 0.0.0 → 2.0.0-beta.85
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/LICENSE +201 -0
- package/README.md +55 -4
- package/lib/Client.d.ts +114 -0
- package/lib/Client.d.ts.map +1 -0
- package/lib/Client.js +166 -0
- package/lib/Client.js.map +1 -0
- package/lib/Errors.d.ts +37 -0
- package/lib/Errors.d.ts.map +1 -0
- package/lib/Errors.js +17 -0
- package/lib/Errors.js.map +1 -0
- package/lib/Protocol.d.ts +47 -0
- package/lib/Protocol.d.ts.map +1 -0
- package/lib/Protocol.js +273 -0
- package/lib/Protocol.js.map +1 -0
- package/lib/Resp.d.ts +65 -0
- package/lib/Resp.d.ts.map +1 -0
- package/lib/Resp.js +566 -0
- package/lib/Resp.js.map +1 -0
- package/lib/RuntimeContext.d.ts +12 -0
- package/lib/RuntimeContext.d.ts.map +1 -0
- package/lib/RuntimeContext.js +1 -0
- package/lib/RuntimeContext.js.map +1 -0
- package/lib/index.d.ts +28 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +28 -0
- package/lib/index.js.map +1 -0
- package/package.json +41 -3
- package/src/Client.ts +303 -0
- package/src/Errors.ts +27 -0
- package/src/Protocol.ts +414 -0
- package/src/Resp.ts +612 -0
- package/src/RuntimeContext.ts +11 -0
- package/src/index.ts +27 -0
package/src/Resp.ts
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RESP2 + RESP3 codec. Commands are encoded as arrays of bulk strings.
|
|
3
|
+
* Replies are parsed with a length-prefixed recursive reader so values
|
|
4
|
+
* may contain CR/LF/`$`/`*` and may arrive split across TCP frames.
|
|
5
|
+
*
|
|
6
|
+
* Spec: https://github.com/redis/redis-specifications/blob/master/protocol/RESP2.md
|
|
7
|
+
* Algorithm: the same as NodeRedis/node-redis-parser, reimplemented here
|
|
8
|
+
* so alchemy does not take a Redis client dependency.
|
|
9
|
+
*/
|
|
10
|
+
import { ProtocolError, ReplyError } from "./Errors.ts";
|
|
11
|
+
|
|
12
|
+
export type Arg = string | number | Uint8Array;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A decoded RESP value. Nested errors stay as {@link ReplyError} values
|
|
16
|
+
* (arrays may contain them). Top-level errors fail the command.
|
|
17
|
+
*/
|
|
18
|
+
export type Reply =
|
|
19
|
+
| string
|
|
20
|
+
| number
|
|
21
|
+
| boolean
|
|
22
|
+
| bigint
|
|
23
|
+
| null
|
|
24
|
+
| ReplyError
|
|
25
|
+
| readonly Reply[]
|
|
26
|
+
| { readonly [key: string]: Reply };
|
|
27
|
+
|
|
28
|
+
export type ParseResult =
|
|
29
|
+
| { readonly _tag: "Incomplete" }
|
|
30
|
+
| { readonly _tag: "Reply"; readonly value: Reply }
|
|
31
|
+
| { readonly _tag: "Error"; readonly error: ReplyError }
|
|
32
|
+
| { readonly _tag: "Push"; readonly value: readonly Reply[] }
|
|
33
|
+
| { readonly _tag: "Protocol"; readonly error: ProtocolError };
|
|
34
|
+
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
const decoder = new TextDecoder("utf-8");
|
|
37
|
+
|
|
38
|
+
const CR = 13;
|
|
39
|
+
const LF = 10;
|
|
40
|
+
const CRLF = new Uint8Array([CR, LF]);
|
|
41
|
+
|
|
42
|
+
const PLUS = 43;
|
|
43
|
+
const MINUS = 45;
|
|
44
|
+
const COLON = 58;
|
|
45
|
+
const DOLLAR = 36;
|
|
46
|
+
const STAR = 42;
|
|
47
|
+
const UNDERSCORE = 95;
|
|
48
|
+
const HASH = 35;
|
|
49
|
+
const COMMA = 44;
|
|
50
|
+
const LPAREN = 40;
|
|
51
|
+
const EQUALS = 61;
|
|
52
|
+
const PERCENT = 37;
|
|
53
|
+
const TILDE = 126;
|
|
54
|
+
const PIPE = 124;
|
|
55
|
+
const GT = 62;
|
|
56
|
+
const BANG = 33;
|
|
57
|
+
const DOT = 46;
|
|
58
|
+
const SEMICOLON = 59;
|
|
59
|
+
const QUESTION = 63;
|
|
60
|
+
|
|
61
|
+
/** Redis bulk strings are capped at 512 MB. */
|
|
62
|
+
const MAX_BULK = 512 * 1024 * 1024;
|
|
63
|
+
const MAX_AGGREGATE = 1_000_000;
|
|
64
|
+
|
|
65
|
+
export const Incomplete: Extract<ParseResult, { _tag: "Incomplete" }> = {
|
|
66
|
+
_tag: "Incomplete",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const concat = (parts: readonly Uint8Array[]): Uint8Array => {
|
|
70
|
+
let total = 0;
|
|
71
|
+
for (const part of parts) total += part.length;
|
|
72
|
+
const out = new Uint8Array(total);
|
|
73
|
+
let offset = 0;
|
|
74
|
+
for (const part of parts) {
|
|
75
|
+
out.set(part, offset);
|
|
76
|
+
offset += part.length;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const bytesOf = (value: string): Uint8Array => encoder.encode(value);
|
|
82
|
+
|
|
83
|
+
const argBytes = (arg: Arg): Uint8Array => {
|
|
84
|
+
if (typeof arg === "string") return bytesOf(arg);
|
|
85
|
+
if (typeof arg === "number") return bytesOf(String(arg));
|
|
86
|
+
return arg;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const encodeSimpleString = (value: string): Uint8Array =>
|
|
90
|
+
concat([bytesOf(`+${value}`), CRLF]);
|
|
91
|
+
|
|
92
|
+
export const encodeError = (value: string): Uint8Array => concat([bytesOf(`-${value}`), CRLF]);
|
|
93
|
+
|
|
94
|
+
export const encodeInteger = (value: number | bigint): Uint8Array =>
|
|
95
|
+
concat([bytesOf(`:${value}`), CRLF]);
|
|
96
|
+
|
|
97
|
+
export const encodeBoolean = (value: boolean): Uint8Array => bytesOf(value ? "#t\r\n" : "#f\r\n");
|
|
98
|
+
|
|
99
|
+
export const encodeDouble = (value: number): Uint8Array => {
|
|
100
|
+
if (Number.isNaN(value)) return bytesOf(",nan\r\n");
|
|
101
|
+
if (value === Infinity) return bytesOf(",inf\r\n");
|
|
102
|
+
if (value === -Infinity) return bytesOf(",-inf\r\n");
|
|
103
|
+
return concat([bytesOf(`,${value}`), CRLF]);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const encodeBigNumber = (value: bigint): Uint8Array => concat([bytesOf(`(${value}`), CRLF]);
|
|
107
|
+
|
|
108
|
+
export const encodeNull = (): Uint8Array => bytesOf("$-1\r\n");
|
|
109
|
+
|
|
110
|
+
export const encodeNullArray = (): Uint8Array => bytesOf("*-1\r\n");
|
|
111
|
+
|
|
112
|
+
export const encodeBulk = (value: string | Uint8Array): Uint8Array => {
|
|
113
|
+
const payload = typeof value === "string" ? bytesOf(value) : value;
|
|
114
|
+
return concat([bytesOf(`$${payload.length}\r\n`), payload, CRLF]);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const encodeArray = (items: readonly Uint8Array[]): Uint8Array =>
|
|
118
|
+
concat([bytesOf(`*${items.length}\r\n`), ...items]);
|
|
119
|
+
|
|
120
|
+
/** Encode a client command as a RESP array of bulk strings. */
|
|
121
|
+
export const encodeCommand = (command: string, args: readonly Arg[] = []): Uint8Array => {
|
|
122
|
+
const parts = [bytesOf(command), ...args.map(argBytes)];
|
|
123
|
+
const chunks: Uint8Array[] = [bytesOf(`*${parts.length}\r\n`)];
|
|
124
|
+
for (const part of parts) {
|
|
125
|
+
chunks.push(bytesOf(`$${part.length}\r\n`), part, CRLF);
|
|
126
|
+
}
|
|
127
|
+
return concat(chunks);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const encodeReply = (value: Reply): Uint8Array => {
|
|
131
|
+
if (value === null) return encodeNull();
|
|
132
|
+
if (typeof value === "string") return encodeBulk(value);
|
|
133
|
+
if (typeof value === "boolean") return encodeBoolean(value);
|
|
134
|
+
if (typeof value === "bigint") {
|
|
135
|
+
if (value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
136
|
+
return encodeInteger(Number(value));
|
|
137
|
+
}
|
|
138
|
+
return encodeBigNumber(value);
|
|
139
|
+
}
|
|
140
|
+
if (typeof value === "number") {
|
|
141
|
+
return Number.isInteger(value) ? encodeInteger(value) : encodeDouble(value);
|
|
142
|
+
}
|
|
143
|
+
if (value instanceof ReplyError) return encodeError(value.message);
|
|
144
|
+
if (Array.isArray(value)) {
|
|
145
|
+
return encodeArray(value.map(encodeReply));
|
|
146
|
+
}
|
|
147
|
+
const entries = Object.entries(value);
|
|
148
|
+
const encoded: Uint8Array[] = [bytesOf(`%${entries.length}\r\n`)];
|
|
149
|
+
for (const [key, item] of entries) {
|
|
150
|
+
encoded.push(encodeSimpleString(key), encodeReply(item));
|
|
151
|
+
}
|
|
152
|
+
return concat(encoded);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
type Line = {
|
|
156
|
+
readonly text: string;
|
|
157
|
+
readonly bytes: Uint8Array;
|
|
158
|
+
readonly next: number;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
type Outcome =
|
|
162
|
+
| {
|
|
163
|
+
readonly ok: true;
|
|
164
|
+
readonly frame: Exclude<ParseResult, { _tag: "Incomplete" | "Protocol" }>;
|
|
165
|
+
readonly offset: number;
|
|
166
|
+
}
|
|
167
|
+
| { readonly ok: false; readonly incomplete: true }
|
|
168
|
+
| { readonly ok: false; readonly error: ProtocolError };
|
|
169
|
+
|
|
170
|
+
const INCOMPLETE: Outcome = { ok: false, incomplete: true };
|
|
171
|
+
|
|
172
|
+
const fail = (message: string): Outcome => ({
|
|
173
|
+
ok: false,
|
|
174
|
+
error: new ProtocolError({ message }),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const ok = (
|
|
178
|
+
frame: Exclude<ParseResult, { _tag: "Incomplete" | "Protocol" }>,
|
|
179
|
+
offset: number,
|
|
180
|
+
): Outcome => ({ ok: true, frame, offset });
|
|
181
|
+
|
|
182
|
+
const parseLine = (buf: Uint8Array, offset: number): Line | undefined | ProtocolError => {
|
|
183
|
+
for (let i = offset; i < buf.length; i++) {
|
|
184
|
+
if (buf[i] === LF) {
|
|
185
|
+
if (i === offset || buf[i - 1] !== CR) {
|
|
186
|
+
return new ProtocolError({ message: "expected CRLF line terminator" });
|
|
187
|
+
}
|
|
188
|
+
const bytes = buf.subarray(offset, i - 1);
|
|
189
|
+
return { text: decoder.decode(bytes), bytes, next: i + 1 };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const parseCount = (
|
|
196
|
+
buf: Uint8Array,
|
|
197
|
+
offset: number,
|
|
198
|
+
):
|
|
199
|
+
| { readonly kind: "null"; readonly next: number }
|
|
200
|
+
| { readonly kind: "stream"; readonly next: number }
|
|
201
|
+
| { readonly kind: "n"; readonly n: number; readonly next: number }
|
|
202
|
+
| { readonly kind: "incomplete" }
|
|
203
|
+
| { readonly kind: "error"; readonly error: ProtocolError } => {
|
|
204
|
+
const line = parseLine(buf, offset);
|
|
205
|
+
if (line === undefined) return { kind: "incomplete" };
|
|
206
|
+
if (line instanceof ProtocolError) return { kind: "error", error: line };
|
|
207
|
+
if (line.text === "?") return { kind: "stream", next: line.next };
|
|
208
|
+
if (line.text === "-1") return { kind: "null", next: line.next };
|
|
209
|
+
if (line.text.length === 0 || !/^-?\d+$/.test(line.text)) {
|
|
210
|
+
return {
|
|
211
|
+
kind: "error",
|
|
212
|
+
error: new ProtocolError({
|
|
213
|
+
message: `invalid length ${JSON.stringify(line.text)}`,
|
|
214
|
+
}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const n = Number(line.text);
|
|
218
|
+
if (!Number.isSafeInteger(n) || n < 0) {
|
|
219
|
+
return {
|
|
220
|
+
kind: "error",
|
|
221
|
+
error: new ProtocolError({ message: `invalid length ${line.text}` }),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (n > MAX_BULK) {
|
|
225
|
+
return {
|
|
226
|
+
kind: "error",
|
|
227
|
+
error: new ProtocolError({ message: `length ${n} exceeds ${MAX_BULK}` }),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return { kind: "n", n, next: line.next };
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER);
|
|
234
|
+
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
235
|
+
|
|
236
|
+
const parseIntegerValue = (text: string): number | bigint => {
|
|
237
|
+
if (text.length === 0 || !/^-?\d+$/.test(text)) {
|
|
238
|
+
throw new ProtocolError({
|
|
239
|
+
message: `invalid integer ${JSON.stringify(text)}`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
const value = BigInt(text);
|
|
243
|
+
if (value >= MIN_SAFE && value <= MAX_SAFE) return Number(value);
|
|
244
|
+
return value;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const parseErrorLine = (text: string): ReplyError => {
|
|
248
|
+
const space = text.indexOf(" ");
|
|
249
|
+
const code = space === -1 ? text : text.slice(0, space);
|
|
250
|
+
return new ReplyError({ code, message: text });
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const asElement = (frame: Exclude<ParseResult, { _tag: "Incomplete" | "Protocol" }>): Reply => {
|
|
254
|
+
if (frame._tag === "Error") return frame.error;
|
|
255
|
+
if (frame._tag === "Push") return frame.value;
|
|
256
|
+
return frame.value;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const parseBulkBody = (buf: Uint8Array, offset: number, n: number): Outcome => {
|
|
260
|
+
const end = offset + n + 2;
|
|
261
|
+
if (end > buf.length) return INCOMPLETE;
|
|
262
|
+
if (buf[offset + n] !== CR || buf[offset + n + 1] !== LF) {
|
|
263
|
+
return fail("missing CRLF after bulk string");
|
|
264
|
+
}
|
|
265
|
+
return ok({ _tag: "Reply", value: decoder.decode(buf.subarray(offset, offset + n)) }, end);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const parseStreamedString = (buf: Uint8Array, offset: number): Outcome => {
|
|
269
|
+
const chunks: Uint8Array[] = [];
|
|
270
|
+
let cursor = offset;
|
|
271
|
+
while (true) {
|
|
272
|
+
if (cursor >= buf.length) return INCOMPLETE;
|
|
273
|
+
if (buf[cursor] !== SEMICOLON) {
|
|
274
|
+
return fail("expected streamed string chunk");
|
|
275
|
+
}
|
|
276
|
+
const count = parseCount(buf, cursor + 1);
|
|
277
|
+
if (count.kind === "incomplete") return INCOMPLETE;
|
|
278
|
+
if (count.kind === "error") return { ok: false, error: count.error };
|
|
279
|
+
if (count.kind !== "n") return fail("invalid streamed string chunk length");
|
|
280
|
+
if (count.n === 0) {
|
|
281
|
+
return ok({ _tag: "Reply", value: decoder.decode(concat(chunks)) }, count.next);
|
|
282
|
+
}
|
|
283
|
+
const body = parseBulkBody(buf, count.next, count.n);
|
|
284
|
+
if (!body.ok) return body;
|
|
285
|
+
if (body.frame._tag !== "Reply" || typeof body.frame.value !== "string") {
|
|
286
|
+
return fail("invalid streamed string chunk");
|
|
287
|
+
}
|
|
288
|
+
chunks.push(buf.subarray(count.next, count.next + count.n));
|
|
289
|
+
cursor = body.offset;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const parseVerbatim = (buf: Uint8Array, offset: number): Outcome => {
|
|
294
|
+
const count = parseCount(buf, offset);
|
|
295
|
+
if (count.kind === "incomplete") return INCOMPLETE;
|
|
296
|
+
if (count.kind === "error") return { ok: false, error: count.error };
|
|
297
|
+
if (count.kind !== "n") return fail("invalid verbatim string length");
|
|
298
|
+
const body = parseBulkBody(buf, count.next, count.n);
|
|
299
|
+
if (!body.ok) return body;
|
|
300
|
+
if (body.frame._tag !== "Reply" || typeof body.frame.value !== "string") {
|
|
301
|
+
return fail("invalid verbatim string");
|
|
302
|
+
}
|
|
303
|
+
const raw = body.frame.value;
|
|
304
|
+
const value = raw.length >= 4 && raw[3] === ":" ? raw.slice(4) : raw;
|
|
305
|
+
return ok({ _tag: "Reply", value }, body.offset);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const parseBlobError = (buf: Uint8Array, offset: number): Outcome => {
|
|
309
|
+
const count = parseCount(buf, offset);
|
|
310
|
+
if (count.kind === "incomplete") return INCOMPLETE;
|
|
311
|
+
if (count.kind === "error") return { ok: false, error: count.error };
|
|
312
|
+
if (count.kind !== "n") return fail("invalid blob error length");
|
|
313
|
+
const body = parseBulkBody(buf, count.next, count.n);
|
|
314
|
+
if (!body.ok) return body;
|
|
315
|
+
if (body.frame._tag !== "Reply" || typeof body.frame.value !== "string") {
|
|
316
|
+
return fail("invalid blob error");
|
|
317
|
+
}
|
|
318
|
+
return ok({ _tag: "Error", error: parseErrorLine(body.frame.value) }, body.offset);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const parseElements = (
|
|
322
|
+
buf: Uint8Array,
|
|
323
|
+
offset: number,
|
|
324
|
+
n: number,
|
|
325
|
+
): { readonly ok: true; readonly values: Reply[]; readonly offset: number } | Outcome => {
|
|
326
|
+
if (n > MAX_AGGREGATE) {
|
|
327
|
+
return fail(`aggregate length ${n} exceeds ${MAX_AGGREGATE}`);
|
|
328
|
+
}
|
|
329
|
+
const values: Reply[] = [];
|
|
330
|
+
let cursor = offset;
|
|
331
|
+
for (let i = 0; i < n; i++) {
|
|
332
|
+
const next = parseFrame(buf, cursor);
|
|
333
|
+
if (!next.ok) return next;
|
|
334
|
+
values.push(asElement(next.frame));
|
|
335
|
+
cursor = next.offset;
|
|
336
|
+
}
|
|
337
|
+
return { ok: true, values, offset: cursor };
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const parseStreamedAggregate = (
|
|
341
|
+
buf: Uint8Array,
|
|
342
|
+
offset: number,
|
|
343
|
+
): { readonly ok: true; readonly values: Reply[]; readonly offset: number } | Outcome => {
|
|
344
|
+
const values: Reply[] = [];
|
|
345
|
+
let cursor = offset;
|
|
346
|
+
while (true) {
|
|
347
|
+
if (cursor >= buf.length) return INCOMPLETE;
|
|
348
|
+
if (buf[cursor] === DOT) {
|
|
349
|
+
const line = parseLine(buf, cursor + 1);
|
|
350
|
+
if (line === undefined) return INCOMPLETE;
|
|
351
|
+
if (line instanceof ProtocolError) return { ok: false, error: line };
|
|
352
|
+
if (line.text.length !== 0) return fail("invalid aggregate terminator");
|
|
353
|
+
return { ok: true, values, offset: line.next };
|
|
354
|
+
}
|
|
355
|
+
const next = parseFrame(buf, cursor);
|
|
356
|
+
if (!next.ok) return next;
|
|
357
|
+
values.push(asElement(next.frame));
|
|
358
|
+
cursor = next.offset;
|
|
359
|
+
if (values.length > MAX_AGGREGATE) {
|
|
360
|
+
return fail(`aggregate length exceeds ${MAX_AGGREGATE}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const pairsToReply = (values: readonly Reply[]): Reply => {
|
|
366
|
+
if (values.length % 2 !== 0) {
|
|
367
|
+
return values;
|
|
368
|
+
}
|
|
369
|
+
const entries: Array<[Reply, Reply]> = [];
|
|
370
|
+
for (let i = 0; i < values.length; i += 2) {
|
|
371
|
+
entries.push([values[i]!, values[i + 1]!]);
|
|
372
|
+
}
|
|
373
|
+
const out: Record<string, Reply> = {};
|
|
374
|
+
for (const [key, value] of entries) {
|
|
375
|
+
if (typeof key !== "string") return values;
|
|
376
|
+
out[key] = value;
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const parseAggregate = (
|
|
382
|
+
buf: Uint8Array,
|
|
383
|
+
offset: number,
|
|
384
|
+
kind: "array" | "map" | "set" | "push",
|
|
385
|
+
): Outcome => {
|
|
386
|
+
const count = parseCount(buf, offset);
|
|
387
|
+
if (count.kind === "incomplete") return INCOMPLETE;
|
|
388
|
+
if (count.kind === "error") return { ok: false, error: count.error };
|
|
389
|
+
if (count.kind === "null") {
|
|
390
|
+
return ok({ _tag: "Reply", value: null }, count.next);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const parsed =
|
|
394
|
+
count.kind === "stream"
|
|
395
|
+
? parseStreamedAggregate(buf, count.next)
|
|
396
|
+
: parseElements(buf, count.next, kind === "map" ? count.n * 2 : count.n);
|
|
397
|
+
if (!("values" in parsed)) return parsed;
|
|
398
|
+
|
|
399
|
+
if (kind === "push") {
|
|
400
|
+
return ok({ _tag: "Push", value: parsed.values }, parsed.offset);
|
|
401
|
+
}
|
|
402
|
+
if (kind === "map") {
|
|
403
|
+
return ok({ _tag: "Reply", value: pairsToReply(parsed.values) }, parsed.offset);
|
|
404
|
+
}
|
|
405
|
+
return ok({ _tag: "Reply", value: parsed.values }, parsed.offset);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const parseBulk = (buf: Uint8Array, offset: number): Outcome => {
|
|
409
|
+
const count = parseCount(buf, offset);
|
|
410
|
+
if (count.kind === "incomplete") return INCOMPLETE;
|
|
411
|
+
if (count.kind === "error") return { ok: false, error: count.error };
|
|
412
|
+
if (count.kind === "null") {
|
|
413
|
+
return ok({ _tag: "Reply", value: null }, count.next);
|
|
414
|
+
}
|
|
415
|
+
if (count.kind === "stream") return parseStreamedString(buf, count.next);
|
|
416
|
+
return parseBulkBody(buf, count.next, count.n);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
function parseFrame(buf: Uint8Array, offset: number): Outcome {
|
|
420
|
+
let cursor = offset;
|
|
421
|
+
while (cursor < buf.length && buf[cursor] === PIPE) {
|
|
422
|
+
const attrs = parseAggregate(buf, cursor + 1, "map");
|
|
423
|
+
if (!attrs.ok) return attrs;
|
|
424
|
+
cursor = attrs.offset;
|
|
425
|
+
}
|
|
426
|
+
if (cursor >= buf.length) return INCOMPLETE;
|
|
427
|
+
|
|
428
|
+
const type = buf[cursor]!;
|
|
429
|
+
const next = cursor + 1;
|
|
430
|
+
switch (type) {
|
|
431
|
+
case PLUS: {
|
|
432
|
+
const line = parseLine(buf, next);
|
|
433
|
+
if (line === undefined) return INCOMPLETE;
|
|
434
|
+
if (line instanceof ProtocolError) return { ok: false, error: line };
|
|
435
|
+
return ok({ _tag: "Reply", value: line.text }, line.next);
|
|
436
|
+
}
|
|
437
|
+
case MINUS: {
|
|
438
|
+
const line = parseLine(buf, next);
|
|
439
|
+
if (line === undefined) return INCOMPLETE;
|
|
440
|
+
if (line instanceof ProtocolError) return { ok: false, error: line };
|
|
441
|
+
return ok({ _tag: "Error", error: parseErrorLine(line.text) }, line.next);
|
|
442
|
+
}
|
|
443
|
+
case COLON: {
|
|
444
|
+
const line = parseLine(buf, next);
|
|
445
|
+
if (line === undefined) return INCOMPLETE;
|
|
446
|
+
if (line instanceof ProtocolError) return { ok: false, error: line };
|
|
447
|
+
try {
|
|
448
|
+
return ok({ _tag: "Reply", value: parseIntegerValue(line.text) }, line.next);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
return error instanceof ProtocolError ? { ok: false, error } : fail(String(error));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
case DOLLAR:
|
|
454
|
+
return parseBulk(buf, next);
|
|
455
|
+
case STAR:
|
|
456
|
+
return parseAggregate(buf, next, "array");
|
|
457
|
+
case UNDERSCORE: {
|
|
458
|
+
const line = parseLine(buf, next);
|
|
459
|
+
if (line === undefined) return INCOMPLETE;
|
|
460
|
+
if (line instanceof ProtocolError) return { ok: false, error: line };
|
|
461
|
+
if (line.text.length !== 0) return fail("invalid null");
|
|
462
|
+
return ok({ _tag: "Reply", value: null }, line.next);
|
|
463
|
+
}
|
|
464
|
+
case HASH: {
|
|
465
|
+
const line = parseLine(buf, next);
|
|
466
|
+
if (line === undefined) return INCOMPLETE;
|
|
467
|
+
if (line instanceof ProtocolError) return { ok: false, error: line };
|
|
468
|
+
if (line.text === "t") return ok({ _tag: "Reply", value: true }, line.next);
|
|
469
|
+
if (line.text === "f") return ok({ _tag: "Reply", value: false }, line.next);
|
|
470
|
+
return fail(`invalid boolean ${JSON.stringify(line.text)}`);
|
|
471
|
+
}
|
|
472
|
+
case COMMA: {
|
|
473
|
+
const line = parseLine(buf, next);
|
|
474
|
+
if (line === undefined) return INCOMPLETE;
|
|
475
|
+
if (line instanceof ProtocolError) return { ok: false, error: line };
|
|
476
|
+
const text = line.text.toLowerCase();
|
|
477
|
+
if (text === "nan" || text === "-nan") {
|
|
478
|
+
return ok({ _tag: "Reply", value: Number.NaN }, line.next);
|
|
479
|
+
}
|
|
480
|
+
if (text === "inf") return ok({ _tag: "Reply", value: Infinity }, line.next);
|
|
481
|
+
if (text === "-inf") {
|
|
482
|
+
return ok({ _tag: "Reply", value: -Infinity }, line.next);
|
|
483
|
+
}
|
|
484
|
+
if (!/^-?(?:\d+)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(line.text)) {
|
|
485
|
+
return fail(`invalid double ${JSON.stringify(line.text)}`);
|
|
486
|
+
}
|
|
487
|
+
return ok({ _tag: "Reply", value: Number(line.text) }, line.next);
|
|
488
|
+
}
|
|
489
|
+
case LPAREN: {
|
|
490
|
+
const line = parseLine(buf, next);
|
|
491
|
+
if (line === undefined) return INCOMPLETE;
|
|
492
|
+
if (line instanceof ProtocolError) return { ok: false, error: line };
|
|
493
|
+
if (line.text.length === 0 || !/^-?\d+$/.test(line.text)) {
|
|
494
|
+
return fail(`invalid big number ${JSON.stringify(line.text)}`);
|
|
495
|
+
}
|
|
496
|
+
return ok({ _tag: "Reply", value: BigInt(line.text) }, line.next);
|
|
497
|
+
}
|
|
498
|
+
case EQUALS:
|
|
499
|
+
return parseVerbatim(buf, next);
|
|
500
|
+
case PERCENT:
|
|
501
|
+
return parseAggregate(buf, next, "map");
|
|
502
|
+
case TILDE:
|
|
503
|
+
return parseAggregate(buf, next, "set");
|
|
504
|
+
case GT:
|
|
505
|
+
return parseAggregate(buf, next, "push");
|
|
506
|
+
case BANG:
|
|
507
|
+
return parseBlobError(buf, next);
|
|
508
|
+
case QUESTION:
|
|
509
|
+
return fail("unexpected '?' type byte");
|
|
510
|
+
default:
|
|
511
|
+
return fail(
|
|
512
|
+
`unexpected type byte ${JSON.stringify(String.fromCharCode(type))} (0x${type.toString(16)})`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Incremental RESP parser. Feed TCP chunks with {@link Parser.push} and
|
|
519
|
+
* pull complete frames with {@link Parser.next}.
|
|
520
|
+
*/
|
|
521
|
+
export class Parser {
|
|
522
|
+
#buffer: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
|
523
|
+
#offset = 0;
|
|
524
|
+
|
|
525
|
+
push(chunk: Uint8Array): void {
|
|
526
|
+
if (chunk.length === 0) return;
|
|
527
|
+
if (this.#offset >= this.#buffer.length) {
|
|
528
|
+
this.#buffer = chunk;
|
|
529
|
+
this.#offset = 0;
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const remaining = this.#buffer.subarray(this.#offset);
|
|
533
|
+
const next = new Uint8Array(remaining.length + chunk.length);
|
|
534
|
+
next.set(remaining, 0);
|
|
535
|
+
next.set(chunk, remaining.length);
|
|
536
|
+
this.#buffer = next;
|
|
537
|
+
this.#offset = 0;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
#compact(): void {
|
|
541
|
+
if (this.#offset === 0) return;
|
|
542
|
+
if (this.#offset >= this.#buffer.length) {
|
|
543
|
+
this.#buffer = new Uint8Array(0);
|
|
544
|
+
this.#offset = 0;
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
this.#buffer = this.#buffer.slice(this.#offset);
|
|
548
|
+
this.#offset = 0;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Unparsed bytes still buffered. */
|
|
552
|
+
get pending(): number {
|
|
553
|
+
return this.#buffer.length - this.#offset;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
next(): ParseResult {
|
|
557
|
+
const result = parseFrame(this.#buffer, this.#offset);
|
|
558
|
+
if (!result.ok) {
|
|
559
|
+
if ("incomplete" in result) {
|
|
560
|
+
this.#compact();
|
|
561
|
+
return Incomplete;
|
|
562
|
+
}
|
|
563
|
+
return { _tag: "Protocol", error: result.error };
|
|
564
|
+
}
|
|
565
|
+
this.#offset = result.offset;
|
|
566
|
+
this.#compact();
|
|
567
|
+
return result.frame;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Parse one complete frame. Incomplete or leftover bytes are protocol errors. */
|
|
572
|
+
export const decode = (bytes: Uint8Array | string): ParseResult => {
|
|
573
|
+
const buf = typeof bytes === "string" ? bytesOf(bytes) : bytes;
|
|
574
|
+
const parser = new Parser();
|
|
575
|
+
parser.push(buf);
|
|
576
|
+
const result = parser.next();
|
|
577
|
+
if (result._tag === "Incomplete") {
|
|
578
|
+
return {
|
|
579
|
+
_tag: "Protocol",
|
|
580
|
+
error: new ProtocolError({ message: "incomplete RESP value" }),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
if (parser.pending > 0 && result._tag !== "Protocol") {
|
|
584
|
+
return {
|
|
585
|
+
_tag: "Protocol",
|
|
586
|
+
error: new ProtocolError({ message: "unexpected trailing RESP bytes" }),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
return result;
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
/** Parse every complete frame in `bytes`. Trailing incomplete data errors. */
|
|
593
|
+
export const decodeAll = (bytes: Uint8Array | string): ParseResult[] => {
|
|
594
|
+
const buf = typeof bytes === "string" ? bytesOf(bytes) : bytes;
|
|
595
|
+
const parser = new Parser();
|
|
596
|
+
parser.push(buf);
|
|
597
|
+
const frames: ParseResult[] = [];
|
|
598
|
+
while (true) {
|
|
599
|
+
const result = parser.next();
|
|
600
|
+
if (result._tag === "Incomplete") {
|
|
601
|
+
if (parser.pending > 0 || frames.length === 0) {
|
|
602
|
+
frames.push({
|
|
603
|
+
_tag: "Protocol",
|
|
604
|
+
error: new ProtocolError({ message: "incomplete RESP value" }),
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
return frames;
|
|
608
|
+
}
|
|
609
|
+
frames.push(result);
|
|
610
|
+
if (result._tag === "Protocol") return frames;
|
|
611
|
+
}
|
|
612
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type * as Context from "effect/Context";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Structural twin of `alchemy/RuntimeContext`'s service identifier, so this
|
|
5
|
+
* package colors its client as runtime-only without depending on `alchemy`.
|
|
6
|
+
*
|
|
7
|
+
* Only the identifier matters (`"RuntimeContext"`); the service shape is left
|
|
8
|
+
* opaque because the client never reads it. It must stay mutually assignable
|
|
9
|
+
* with the real one so a host providing `RuntimeContext` satisfies it.
|
|
10
|
+
*/
|
|
11
|
+
export interface RuntimeContext extends Context.ServiceClass.Shape<"RuntimeContext", any> {}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud-agnostic Redis runtime client. Fly and Railway resources bind
|
|
3
|
+
* `REDIS_URL`; this module speaks RESP over that URL. Import from
|
|
4
|
+
* `@alchemy.run/redis`.
|
|
5
|
+
*
|
|
6
|
+
* The codec is a complete RESP2 parser (plus RESP3 types) reimplemented
|
|
7
|
+
* in TypeScript after the official spec and the algorithm in
|
|
8
|
+
* `node-redis-parser`. There is no ioredis / node-redis dependency.
|
|
9
|
+
*
|
|
10
|
+
* Bindings stay on the cloud (`Fly.ReadWriteRedis`,
|
|
11
|
+
* `Railway.ReadWriteRedis`). Those layers call {@link makeReadWrite}
|
|
12
|
+
* so the RESP client is not duplicated.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import * as Redis from "@alchemy.run/redis";
|
|
17
|
+
*
|
|
18
|
+
* const cache = Redis.makeReadWrite(url);
|
|
19
|
+
* yield* cache.set("marker", "hello");
|
|
20
|
+
* const value = yield* cache.get("marker");
|
|
21
|
+
* const custom = yield* cache.send("ECHO", ["hi"]);
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export * from "./Client.ts";
|
|
25
|
+
export * from "./Errors.ts";
|
|
26
|
+
export * from "./Protocol.ts";
|
|
27
|
+
export * from "./Resp.ts";
|