@zeyolabs/kit 0.0.0-stage → 2026.11.5
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 +37 -2
- package/cloudflare/access.ts +134 -0
- package/cloudflare/amqp/client.ts +276 -0
- package/cloudflare/amqp/testing.ts +9 -0
- package/cloudflare/context.ts +66 -0
- package/cloudflare/dispatch/client.ts +30 -0
- package/cloudflare/email/client.ts +77 -0
- package/cloudflare/email/dev.ts +10 -0
- package/cloudflare/email/testing.ts +13 -0
- package/cloudflare/r2/client.ts +154 -0
- package/cloudflare/r2/testing.ts +43 -0
- package/cloudflare/rate-limiter.ts +65 -0
- package/cloudflare/wait-until.ts +12 -0
- package/cloudflare/workers/index.ts +311 -0
- package/cloudflare/workers/testing.ts +94 -0
- package/cloudflare/workers/ticking-do.ts +104 -0
- package/cloudflare/workers-ai/client.ts +30 -0
- package/cloudflare/workers-ai/testing.ts +17 -0
- package/cloudflare/workers-env.ts +12 -0
- package/eslint-config.js +115 -0
- package/monitoring/alerts/client.ts +48 -0
- package/monitoring/alerts/event-config.ts +33 -0
- package/monitoring/alerts/policy.ts +39 -0
- package/monitoring/alerts/registry.ts +44 -0
- package/monitoring/gates/client.ts +90 -0
- package/monitoring/gates/dev.ts +29 -0
- package/monitoring/gates/testing.ts +30 -0
- package/monitoring/logger/client.ts +66 -0
- package/monitoring/prometheus/client.ts +147 -0
- package/monitoring/prometheus/dev.ts +23 -0
- package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
- package/monitoring/prometheus/metrics.ts +180 -0
- package/monitoring/prometheus/testing.ts +19 -0
- package/monitoring/slack/api.ts +68 -0
- package/monitoring/slack/client.ts +33 -0
- package/monitoring/slack/events.ts +15 -0
- package/package.json +150 -5
- package/server/logger.ts +63 -0
- package/server/rate-limit-client.ts +88 -0
- package/server/rate-limit.ts +24 -0
- package/server/remote-functions.ts +139 -0
- package/server/result.ts +18 -0
- package/server/turnstile.ts +19 -0
- package/storage/clickhouse/client.ts +441 -0
- package/storage/clickhouse/compiler.ts +218 -0
- package/storage/clickhouse/config.ts +13 -0
- package/storage/clickhouse/dev.ts +18 -0
- package/storage/clickhouse/error.ts +61 -0
- package/storage/clickhouse/index.ts +4 -0
- package/storage/clickhouse/native-decoder.ts +192 -0
- package/storage/clickhouse/readers.ts +167 -0
- package/storage/clickhouse/retry.ts +30 -0
- package/storage/clickhouse/schema.ts +163 -0
- package/storage/clickhouse/testing.ts +81 -0
- package/storage/manticore/client.ts +165 -0
- package/storage/manticore/schema.ts +23 -0
- package/storage/postgres/client.ts +44 -0
- package/storage/postgres/testing.ts +275 -0
- package/storage/redis/client.ts +219 -0
- package/storage/redis/protocol.ts +119 -0
- package/storage/redis/schema.ts +17 -0
- package/storage/redis/testing.ts +122 -0
- package/utils/crypto.ts +36 -0
- package/utils/datetime.ts +338 -0
- package/utils/email.ts +173 -0
- package/utils/env.ts +171 -0
- package/utils/errors.ts +67 -0
- package/utils/form.ts +135 -0
- package/utils/http-status.ts +42 -0
- package/utils/money.ts +10 -0
- package/utils/public-host.ts +66 -0
- package/utils/schema.ts +211 -0
- package/utils/strings/format.ts +51 -0
- package/utils/strings/normalize.ts +129 -0
- package/utils/transport.ts +25 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { CfSocket, RedisConfig } from './schema';
|
|
2
|
+
|
|
3
|
+
import { logger } from '../../monitoring/logger/client';
|
|
4
|
+
import { encodeCommand, parseReply, RespReader, type RespValue } from './protocol';
|
|
5
|
+
|
|
6
|
+
type Connection = {
|
|
7
|
+
reader: RespReader;
|
|
8
|
+
socket: CfSocket;
|
|
9
|
+
writer: WritableStreamDefaultWriter<Uint8Array>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Two ways a dropped connection surfaces, neither the command's fault: Cloudflare's TCP socket
|
|
13
|
+
* throws this exact message when the underlying connection resets (idle timeout, remote reset);
|
|
14
|
+
* `RespReader` throws its own "connection closed unexpectedly" (+ "(mid-bulk)") when the socket
|
|
15
|
+
* closes cleanly (a `read()` EOF) instead of throwing. Both are worth a same-command retry. */
|
|
16
|
+
function isConnectionLost(err: unknown): boolean {
|
|
17
|
+
return (
|
|
18
|
+
err instanceof Error &&
|
|
19
|
+
(err.message === 'Network connection lost.' ||
|
|
20
|
+
err.message.startsWith('Redis connection closed unexpectedly'))
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `HGETALL`'s flat `[field, value, field, value, ...]` reply as a plain object — `{}` for the
|
|
25
|
+
* empty-array reply a nonexistent key gives back. */
|
|
26
|
+
function flattenHash(flat: RespValue[]): Record<string, string> {
|
|
27
|
+
const out: Record<string, string> = {};
|
|
28
|
+
for (let i = 0; i < flat.length; i += 2) out[flat[i] as string] = flat[i + 1] as string;
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Just the shape callers depend on — a test can hand-write a fake object matching this instead
|
|
33
|
+
* of constructing `RedisClient` with a fake socket/VPC binding. */
|
|
34
|
+
export interface IRedisClient {
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
command(args: (number | string)[]): Promise<RespValue>;
|
|
37
|
+
eval(script: string, keys: string[], args: (number | string)[]): Promise<RespValue>;
|
|
38
|
+
hDel(key: string, ...fields: string[]): Promise<number>;
|
|
39
|
+
hGetAll(key: string): Promise<Record<string, string>>;
|
|
40
|
+
hGetAllMany(keys: string[]): Promise<Record<string, string>[]>;
|
|
41
|
+
hSet(key: string, fields: Record<string, number | string>): Promise<number>;
|
|
42
|
+
pipeline(commandsList: (number | string)[][]): Promise<RespValue[]>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Thin Redis client over a hand-rolled RESP2 codec (`./protocol`) — one TCP connection per
|
|
46
|
+
* instance, opened lazily on first `command()` and cached for the instance's life. Per-request
|
|
47
|
+
* (a Workers TCP socket can't cross requests), same profile as `ManticoreClient`. `HGETALL`/
|
|
48
|
+
* `HSET`/`HDEL`/`EVAL` are exposed as typed methods; `command()` is the escape hatch for
|
|
49
|
+
* anything else. */
|
|
50
|
+
export class RedisClient implements IRedisClient {
|
|
51
|
+
private connPromise: null | Promise<Connection> = null;
|
|
52
|
+
// RESP is one request/reply pair at a time over a single connection — a socket's
|
|
53
|
+
// ReadableStreamDefaultReader also physically rejects a second concurrent `read()` (`This
|
|
54
|
+
// ReadableStream only supports a single pending read request at a time`). Callers do issue
|
|
55
|
+
// concurrent commands on one instance (e.g. a bulk pacing load fires N HGETALLs in parallel),
|
|
56
|
+
// so every command is queued through this chain to run strictly one at a time regardless of
|
|
57
|
+
// call order — `.then(task, task)` runs the next task whether the previous one resolved or
|
|
58
|
+
// rejected, and the reassignment always resolves so one failed command never wedges the queue.
|
|
59
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
60
|
+
|
|
61
|
+
constructor(private readonly config: RedisConfig) {}
|
|
62
|
+
|
|
63
|
+
/** Close the connection if one was opened. Safe when never opened and best-effort — runs in the
|
|
64
|
+
* request-teardown `finally`, so a failed close must not throw. */
|
|
65
|
+
async close(): Promise<void> {
|
|
66
|
+
if (!this.connPromise) return;
|
|
67
|
+
const conn = await this.connPromise.catch(() => null);
|
|
68
|
+
this.connPromise = null;
|
|
69
|
+
if (!conn) return;
|
|
70
|
+
try {
|
|
71
|
+
await conn.socket.close();
|
|
72
|
+
} catch {
|
|
73
|
+
// best-effort teardown — nothing actionable on a failed close
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Run an arbitrary RESP command — the escape hatch `hGetAll`/`hSet` are built on. Queued (see
|
|
78
|
+
* `queue`) so concurrent callers never share the connection's reader at the same instant. */
|
|
79
|
+
async command(args: (number | string)[]): Promise<RespValue> {
|
|
80
|
+
const run = async (): Promise<RespValue> => {
|
|
81
|
+
const conn = await this.getConnection();
|
|
82
|
+
await conn.writer.write(encodeCommand(args));
|
|
83
|
+
return await parseReply(conn.reader);
|
|
84
|
+
};
|
|
85
|
+
const attempt = async (): Promise<RespValue> => {
|
|
86
|
+
try {
|
|
87
|
+
return await run();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// A dead connection (idle timeout, socket reset) leaves `connPromise` cached but unusable —
|
|
90
|
+
// drop it so a retry (or the next call, if this was already the retry) reconnects instead
|
|
91
|
+
// of reusing the same dead connection.
|
|
92
|
+
this.connPromise = null;
|
|
93
|
+
if (!isConnectionLost(err)) {
|
|
94
|
+
await logger.error(err, { op: 'redis', summary: String(args[0]) });
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return await run();
|
|
99
|
+
} catch (retryErr) {
|
|
100
|
+
this.connPromise = null;
|
|
101
|
+
await logger.error(retryErr, { op: 'redis', retried: true, summary: String(args[0]) });
|
|
102
|
+
throw retryErr;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const result = this.queue.then(attempt, attempt);
|
|
107
|
+
this.queue = result.then(
|
|
108
|
+
() => undefined,
|
|
109
|
+
() => undefined
|
|
110
|
+
);
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `EVAL script numkeys key [key ...] arg [arg ...]` — the atomicity primitive for a
|
|
115
|
+
* check-and-mutate a plain `HSET`/`HINCRBY` can't express on its own (Redis runs the whole
|
|
116
|
+
* script as one operation, no other command can interleave). */
|
|
117
|
+
async eval(script: string, keys: string[], args: (number | string)[]): Promise<RespValue> {
|
|
118
|
+
return this.command(['EVAL', script, String(keys.length), ...keys, ...args]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `HDEL key field [field ...]` — returns the count of fields actually removed. Absent, not
|
|
122
|
+
* empty-string, is how a cleared stamp reads back (matching Postgres NULL, not `''`). */
|
|
123
|
+
async hDel(key: string, ...fields: string[]): Promise<number> {
|
|
124
|
+
return (await this.command(['HDEL', key, ...fields])) as number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `HGETALL key` as a plain field→value object — `{}` for a nonexistent key, matching Redis'
|
|
128
|
+
* own empty-array reply rather than a null one. */
|
|
129
|
+
async hGetAll(key: string): Promise<Record<string, string>> {
|
|
130
|
+
return flattenHash((await this.command(['HGETALL', key])) as RespValue[]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `HGETALL` for many keys as one pipelined round trip instead of one round trip per key —
|
|
134
|
+
* same result shape as `Promise.all(keys.map((k) => hGetAll(k)))`, in the same order, but the
|
|
135
|
+
* N commands share a single network round trip (see `pipeline`). Use this over a manual
|
|
136
|
+
* `Promise.all` of `hGetAll` calls whenever `keys.length` isn't always 1 — that pattern is
|
|
137
|
+
* exactly what used to race the shared connection reader before `pipeline` existed. */
|
|
138
|
+
async hGetAllMany(keys: string[]): Promise<Record<string, string>[]> {
|
|
139
|
+
if (keys.length === 0) return [];
|
|
140
|
+
const replies = await this.pipeline(keys.map((key) => ['HGETALL', key]));
|
|
141
|
+
return replies.map((flat) => flattenHash(flat as RespValue[]));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `HSET key field value [field value ...]` — returns the count of NEW fields (Redis' own
|
|
145
|
+
* semantics: an existing field being overwritten doesn't count). */
|
|
146
|
+
async hSet(key: string, fields: Record<string, number | string>): Promise<number> {
|
|
147
|
+
const args: (number | string)[] = ['HSET', key];
|
|
148
|
+
for (const [field, value] of Object.entries(fields)) args.push(field, value);
|
|
149
|
+
return (await this.command(args)) as number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Send several commands as one network round trip instead of one round trip per command —
|
|
153
|
+
* all writes go out before any reply is read back; RESP guarantees replies come back in the
|
|
154
|
+
* same order the commands were sent, so `results[i]` answers `commandsList[i]`. Queued the
|
|
155
|
+
* same as `command()` (as one unit — another caller's command can't land in the middle of a
|
|
156
|
+
* pipeline's writes or reads). Prefer a typed wrapper (`hGetAllMany`) over calling this
|
|
157
|
+
* directly where one exists. */
|
|
158
|
+
async pipeline(commandsList: (number | string)[][]): Promise<RespValue[]> {
|
|
159
|
+
const run = async (): Promise<RespValue[]> => {
|
|
160
|
+
const conn = await this.getConnection();
|
|
161
|
+
for (const args of commandsList) await conn.writer.write(encodeCommand(args));
|
|
162
|
+
const results: RespValue[] = [];
|
|
163
|
+
for (let i = 0; i < commandsList.length; i++) {
|
|
164
|
+
results.push(await parseReply(conn.reader));
|
|
165
|
+
}
|
|
166
|
+
return results;
|
|
167
|
+
};
|
|
168
|
+
const attempt = async (): Promise<RespValue[]> => {
|
|
169
|
+
try {
|
|
170
|
+
return await run();
|
|
171
|
+
} catch (err) {
|
|
172
|
+
this.connPromise = null;
|
|
173
|
+
if (!isConnectionLost(err)) {
|
|
174
|
+
await logger.error(err, { op: 'redis', summary: `pipeline(${commandsList.length})` });
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
return await run();
|
|
179
|
+
} catch (retryErr) {
|
|
180
|
+
this.connPromise = null;
|
|
181
|
+
await logger.error(retryErr, {
|
|
182
|
+
op: 'redis',
|
|
183
|
+
retried: true,
|
|
184
|
+
summary: `pipeline(${commandsList.length})`,
|
|
185
|
+
});
|
|
186
|
+
throw retryErr;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
const result = this.queue.then(attempt, attempt);
|
|
191
|
+
this.queue = result.then(
|
|
192
|
+
() => undefined,
|
|
193
|
+
() => undefined
|
|
194
|
+
);
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Workers' `connect()` requires a non-empty address, but the VPC service routes to its own
|
|
199
|
+
* registered target (`wrangler vpc service list`) and ignores this value — same as
|
|
200
|
+
* `ManticoreClient`. */
|
|
201
|
+
private async dialVpc(): Promise<CfSocket> {
|
|
202
|
+
return this.config.vpc.connect('127.0.0.1:6379');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private getConnection(): Promise<Connection> {
|
|
206
|
+
return (this.connPromise ??= (async () => {
|
|
207
|
+
const socket = await this.dialVpc();
|
|
208
|
+
const socketReader = socket.readable.getReader();
|
|
209
|
+
const reader = new RespReader({ read: () => socketReader.read() });
|
|
210
|
+
const writer = socket.writable.getWriter();
|
|
211
|
+
|
|
212
|
+
await writer.write(encodeCommand(['AUTH', this.config.user, this.config.password]));
|
|
213
|
+
const authReply = await parseReply(reader);
|
|
214
|
+
if (authReply !== 'OK') throw new Error(`Redis AUTH failed: ${String(authReply)}`);
|
|
215
|
+
|
|
216
|
+
return { reader, socket, writer };
|
|
217
|
+
})());
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Hand-rolled RESP2 (Redis Serialization Protocol) codec — no Redis client runs on Workers (no
|
|
2
|
+
// Node `net`), same reasoning as `packages/core/server/mail/imap/client.ts`'s own hand-rolled
|
|
3
|
+
// IMAP parser over raw `cloudflare:sockets`. RESP2 is small enough (a handful of type-prefixed
|
|
4
|
+
// lines) that hand-rolling it is less risk than gambling on an npm client's socket-shim
|
|
5
|
+
// compatibility with the Workers runtime.
|
|
6
|
+
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
const decoder = new TextDecoder();
|
|
9
|
+
|
|
10
|
+
export type RespValue = null | number | RespValue[] | string;
|
|
11
|
+
|
|
12
|
+
/** Matches `packages/core/server/mail/imap/protocol.ts`'s `ByteSource` shape — a thin seam over a
|
|
13
|
+
* `ReadableStreamDefaultReader<Uint8Array>` (real sockets) or a mock (tests). */
|
|
14
|
+
export type ByteSource = { read(): Promise<{ done: boolean; value?: Uint8Array }> };
|
|
15
|
+
|
|
16
|
+
/** Encode a command as a RESP2 array of bulk strings — the standard Redis request form
|
|
17
|
+
* (`*<argc>\r\n$<len>\r\n<bytes>\r\n...`). Every arg is sent as a bulk string regardless of type;
|
|
18
|
+
* Redis itself is untyped on the wire. */
|
|
19
|
+
export function encodeCommand(args: (number | string)[]): Uint8Array {
|
|
20
|
+
const parts: Uint8Array[] = [encoder.encode(`*${args.length}\r\n`)];
|
|
21
|
+
for (const arg of args) {
|
|
22
|
+
const bytes = encoder.encode(String(arg));
|
|
23
|
+
parts.push(encoder.encode(`$${bytes.length}\r\n`), bytes, encoder.encode('\r\n'));
|
|
24
|
+
}
|
|
25
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
26
|
+
let offset = 0;
|
|
27
|
+
for (const part of parts) {
|
|
28
|
+
out.set(part, offset);
|
|
29
|
+
offset += part.length;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Buffers raw bytes off a `ByteSource` and slices out RESP frames (a line, or an exact-length
|
|
35
|
+
* bulk payload) without decoding anything it doesn't have to — a bulk string's `$<len>` is a BYTE
|
|
36
|
+
* length, so this must stay byte-buffered rather than accumulating a JS string (which the IMAP
|
|
37
|
+
* reader can get away with, since IMAP literals are ASCII-length-prefixed text). */
|
|
38
|
+
export class RespReader {
|
|
39
|
+
private buf = new Uint8Array(0);
|
|
40
|
+
private offset = 0;
|
|
41
|
+
|
|
42
|
+
constructor(private readonly source: ByteSource) {}
|
|
43
|
+
|
|
44
|
+
/** Read exactly `n` bytes, then discard the trailing CRLF a RESP bulk string always carries. */
|
|
45
|
+
async readExact(n: number): Promise<Uint8Array> {
|
|
46
|
+
while (this.buf.length - this.offset < n + 2) {
|
|
47
|
+
if (!(await this.fill())) throw new Error('Redis connection closed unexpectedly (mid-bulk)');
|
|
48
|
+
}
|
|
49
|
+
const bytes = this.buf.subarray(this.offset, this.offset + n);
|
|
50
|
+
this.offset += n + 2;
|
|
51
|
+
return bytes;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read one CRLF-terminated line, without the CRLF. */
|
|
55
|
+
async readLine(): Promise<Uint8Array> {
|
|
56
|
+
for (;;) {
|
|
57
|
+
const idx = this.indexOfCrlf();
|
|
58
|
+
if (idx !== -1) {
|
|
59
|
+
const line = this.buf.subarray(this.offset, idx);
|
|
60
|
+
this.offset = idx + 2;
|
|
61
|
+
return line;
|
|
62
|
+
}
|
|
63
|
+
if (!(await this.fill())) throw new Error('Redis connection closed unexpectedly');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async fill(): Promise<boolean> {
|
|
68
|
+
const { done, value } = await this.source.read();
|
|
69
|
+
if (done) return false;
|
|
70
|
+
if (value && value.length > 0) {
|
|
71
|
+
const merged = new Uint8Array(this.buf.length - this.offset + value.length);
|
|
72
|
+
merged.set(this.buf.subarray(this.offset));
|
|
73
|
+
merged.set(value, this.buf.length - this.offset);
|
|
74
|
+
this.buf = merged;
|
|
75
|
+
this.offset = 0;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private indexOfCrlf(): number {
|
|
81
|
+
for (let i = this.offset; i < this.buf.length - 1; i++) {
|
|
82
|
+
if (this.buf[i] === 13 && this.buf[i + 1] === 10) return i;
|
|
83
|
+
}
|
|
84
|
+
return -1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Parse one RESP2 reply — recurses for `*` (array). A `-` (error) reply throws rather than
|
|
89
|
+
* returning, since every caller needs to treat a Redis-level error as a failure, not a value to
|
|
90
|
+
* branch on. */
|
|
91
|
+
export async function parseReply(reader: RespReader): Promise<RespValue> {
|
|
92
|
+
const line = await reader.readLine();
|
|
93
|
+
const type = line[0];
|
|
94
|
+
const rest = decoder.decode(line.subarray(1));
|
|
95
|
+
switch (type) {
|
|
96
|
+
case 0x2b: // '+' simple string
|
|
97
|
+
return rest;
|
|
98
|
+
case 0x2d: // '-' error
|
|
99
|
+
throw new Error(`Redis error: ${rest}`);
|
|
100
|
+
case 0x3a: // ':' integer
|
|
101
|
+
return Number(rest);
|
|
102
|
+
case 0x24: {
|
|
103
|
+
// '$' bulk string
|
|
104
|
+
const len = Number(rest);
|
|
105
|
+
if (len === -1) return null;
|
|
106
|
+
return decoder.decode(await reader.readExact(len));
|
|
107
|
+
}
|
|
108
|
+
case 0x2a: {
|
|
109
|
+
// '*' array
|
|
110
|
+
const count = Number(rest);
|
|
111
|
+
if (count === -1) return null;
|
|
112
|
+
const items: RespValue[] = [];
|
|
113
|
+
for (let i = 0; i < count; i++) items.push(await parseReply(reader));
|
|
114
|
+
return items;
|
|
115
|
+
}
|
|
116
|
+
default:
|
|
117
|
+
throw new Error(`Redis protocol error: unexpected reply type ${String.fromCharCode(type)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A Cloudflare socket (from a VPC Service `.connect()` or `cloudflare:sockets`'s `connect()`):
|
|
2
|
+
* Web streams, the same shape either way — see `packages/core/server/mail/imap/client.ts` for
|
|
3
|
+
* the same technique against a different wire protocol. */
|
|
4
|
+
export type CfSocket = {
|
|
5
|
+
close(): Promise<void>;
|
|
6
|
+
readable: ReadableStream<Uint8Array>;
|
|
7
|
+
writable: WritableStream<Uint8Array>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Resolved Redis connection config — `vpc`'s `.connect()` opens a raw socket to Redis's `:6379`
|
|
11
|
+
* listener. The caller reads it off its own `EnvStore` (`env.vpc('REDIS_VPC')`), not a binding
|
|
12
|
+
* name for this client to look up itself. Same shape as `ManticoreConfig`'s `vpc` field. */
|
|
13
|
+
export type RedisConfig = {
|
|
14
|
+
password: string;
|
|
15
|
+
user: string;
|
|
16
|
+
vpc: Fetcher;
|
|
17
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { RedisClient } from './client';
|
|
2
|
+
import type { RespValue } from './protocol';
|
|
3
|
+
|
|
4
|
+
type StringEntry = { expiresAt?: number; value: string };
|
|
5
|
+
|
|
6
|
+
/** In-memory `RedisClient` stand-in for tests: `hSet`/`hGetAll`/`hGetAllMany`/`hDel`/`command`/
|
|
7
|
+
* `pipeline` round-trip for real, no live connection needed. The returned client also carries a
|
|
8
|
+
* `reset()` that clears all keys, so a caller doesn't have to destructure `{ client, reset }`
|
|
9
|
+
* apart -- `const redis = mockRedis(); ...; redis.reset();`.
|
|
10
|
+
*
|
|
11
|
+
* `RedisClient` is a class with private fields (`connPromise`/`queue`), so no plain object can
|
|
12
|
+
* structurally satisfy it -- the `as unknown as RedisClient` cast below is required, not a
|
|
13
|
+
* shortcut. What that cast does hide is method *coverage*: every method on the cast object must
|
|
14
|
+
* actually behave like the real one, or a caller that reaches past `hSet`/`hGetAll` (e.g. the
|
|
15
|
+
* raw `command()` escape hatch `typed-rows.service.server.ts` uses for `SET ... EX`) gets a
|
|
16
|
+
* silent `undefined is not a function` instead of a compile error. `command()`/`pipeline()` are
|
|
17
|
+
* real here for exactly that reason; `eval()` throws instead of faking a Lua interpreter, since
|
|
18
|
+
* nothing in this codebase calls it yet. */
|
|
19
|
+
export function mockRedis(): RedisClient & { reset: () => void } {
|
|
20
|
+
const hashes = new Map<string, Record<string, string>>();
|
|
21
|
+
const strings = new Map<string, StringEntry>();
|
|
22
|
+
|
|
23
|
+
const hGetAll = async (key: string): Promise<Record<string, string>> => ({
|
|
24
|
+
...(hashes.get(key) ?? {}),
|
|
25
|
+
});
|
|
26
|
+
const hSet = async (key: string, fields: Record<string, number | string>): Promise<number> => {
|
|
27
|
+
const hash = hashes.get(key) ?? {};
|
|
28
|
+
let added = 0;
|
|
29
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
30
|
+
if (!(field in hash)) added++;
|
|
31
|
+
hash[field] = String(value);
|
|
32
|
+
}
|
|
33
|
+
hashes.set(key, hash);
|
|
34
|
+
return added;
|
|
35
|
+
};
|
|
36
|
+
const hDel = async (key: string, ...fields: string[]): Promise<number> => {
|
|
37
|
+
const hash = hashes.get(key);
|
|
38
|
+
if (!hash) return 0;
|
|
39
|
+
let removed = 0;
|
|
40
|
+
for (const field of fields) {
|
|
41
|
+
if (field in hash) {
|
|
42
|
+
delete hash[field];
|
|
43
|
+
removed++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return removed;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Not a real RESP dispatcher -- just the commands this codebase actually issues via the raw
|
|
50
|
+
// escape hatch (SET/GET, with SET's optional `EX <seconds>` ttl) plus HSET/HGETALL/HDEL routed
|
|
51
|
+
// through the same maps the typed methods use, so mixing `command()` and `hSet()` on one fake
|
|
52
|
+
// sees one consistent store. Add a case if a future caller needs one -- the default throws
|
|
53
|
+
// instead of silently no-op'ing so a gap here fails loud, at the call site, immediately.
|
|
54
|
+
const command = async (args: (number | string)[]): Promise<RespValue> => {
|
|
55
|
+
const [cmd, ...rest] = args.map(String);
|
|
56
|
+
switch (cmd.toUpperCase()) {
|
|
57
|
+
case 'DEL': {
|
|
58
|
+
const [key] = rest;
|
|
59
|
+
const existed = strings.delete(key);
|
|
60
|
+
return existed ? 1 : 0;
|
|
61
|
+
}
|
|
62
|
+
case 'GET': {
|
|
63
|
+
const [key] = rest;
|
|
64
|
+
const entry = strings.get(key);
|
|
65
|
+
if (!entry) return null;
|
|
66
|
+
if (entry.expiresAt !== undefined && entry.expiresAt <= Date.now()) {
|
|
67
|
+
strings.delete(key);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return entry.value;
|
|
71
|
+
}
|
|
72
|
+
case 'HDEL': {
|
|
73
|
+
const [key, ...fields] = rest;
|
|
74
|
+
return hDel(key, ...fields);
|
|
75
|
+
}
|
|
76
|
+
case 'HGETALL': {
|
|
77
|
+
const [key] = rest;
|
|
78
|
+
return Object.entries(await hGetAll(key)).flat();
|
|
79
|
+
}
|
|
80
|
+
case 'HSET': {
|
|
81
|
+
const [key, ...pairs] = rest;
|
|
82
|
+
const fields: Record<string, string> = {};
|
|
83
|
+
for (let i = 0; i < pairs.length; i += 2) fields[pairs[i]] = pairs[i + 1];
|
|
84
|
+
return hSet(key, fields);
|
|
85
|
+
}
|
|
86
|
+
case 'SET': {
|
|
87
|
+
const [key, value, ...opts] = rest;
|
|
88
|
+
const exIndex = opts.findIndex((o) => o.toUpperCase() === 'EX');
|
|
89
|
+
const ttlSeconds = exIndex >= 0 ? Number(opts[exIndex + 1]) : undefined;
|
|
90
|
+
strings.set(key, {
|
|
91
|
+
expiresAt: ttlSeconds !== undefined ? Date.now() + ttlSeconds * 1000 : undefined,
|
|
92
|
+
value,
|
|
93
|
+
});
|
|
94
|
+
return 'OK';
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
throw new Error(`mockRedis(): unhandled command "${cmd}" -- add a case if a test needs it`);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
close: async () => {},
|
|
103
|
+
command,
|
|
104
|
+
eval: async () => {
|
|
105
|
+
throw new Error('mockRedis(): eval() is not implemented -- no test needs Lua scripting yet');
|
|
106
|
+
},
|
|
107
|
+
hDel,
|
|
108
|
+
hGetAll,
|
|
109
|
+
hGetAllMany: async (keys: string[]): Promise<Record<string, string>[]> =>
|
|
110
|
+
Promise.all(keys.map((key) => hGetAll(key))),
|
|
111
|
+
hSet,
|
|
112
|
+
pipeline: async (commandsList: (number | string)[][]): Promise<RespValue[]> => {
|
|
113
|
+
const results: RespValue[] = [];
|
|
114
|
+
for (const args of commandsList) results.push(await command(args));
|
|
115
|
+
return results;
|
|
116
|
+
},
|
|
117
|
+
reset: () => {
|
|
118
|
+
hashes.clear();
|
|
119
|
+
strings.clear();
|
|
120
|
+
},
|
|
121
|
+
} as unknown as RedisClient & { reset: () => void };
|
|
122
|
+
}
|
package/utils/crypto.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { sha256 } from '@oslojs/crypto/sha2';
|
|
2
|
+
import { constantTimeEqual } from '@oslojs/crypto/subtle';
|
|
3
|
+
import { decodeHex, encodeHexLowerCase } from '@oslojs/encoding';
|
|
4
|
+
|
|
5
|
+
/** Small, dependency-free crypto primitives shared across every realm that mints or verifies a
|
|
6
|
+
* secret (order links, cron/webhook headers, …) — a plain object of pure functions, not a
|
|
7
|
+
* service: nothing here reads a binding or holds state. A `Crypto` helper, minus the
|
|
8
|
+
* token-at-rest `encrypt`/`decrypt` this project has no use for. */
|
|
9
|
+
export const Crypto = {
|
|
10
|
+
/** sha256 of a secret, hex-encoded — what's actually stored (never the raw secret), so a
|
|
11
|
+
* leaked DB row can't be replayed. Pair with `verifySecret` to check one back. */
|
|
12
|
+
hashSecret(secret: string): string {
|
|
13
|
+
return encodeHexLowerCase(sha256(new TextEncoder().encode(secret)));
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
/** A fresh, random, hex-encoded secret of `byteLength` bytes (defaults to 32 — 256 bits). */
|
|
17
|
+
getRandomSecret(byteLength = 32): string {
|
|
18
|
+
return encodeHexLowerCase(crypto.getRandomValues(new Uint8Array(byteLength)));
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
/** Constant-time string compare for secret-guarded routes/webhooks (a cron-secret header, a
|
|
22
|
+
* webhook's clientState, …). Returns `false` (never throws) on a length mismatch, so this is
|
|
23
|
+
* safe on arbitrary header/query input. */
|
|
24
|
+
timingSafeEquals(a: string, b: string): boolean {
|
|
25
|
+
const enc = new TextEncoder();
|
|
26
|
+
return constantTimeEqual(enc.encode(a), enc.encode(b));
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/** Constant-time check that `secret` hashes to `hash` (as produced by `hashSecret`) — never a
|
|
30
|
+
* plain `===` on the derived hash, or a failed check's timing could leak how many leading
|
|
31
|
+
* bytes matched. */
|
|
32
|
+
verifySecret(secret: string, hash: string): boolean {
|
|
33
|
+
const provided = sha256(new TextEncoder().encode(secret));
|
|
34
|
+
return constantTimeEqual(provided, decodeHex(hash));
|
|
35
|
+
},
|
|
36
|
+
};
|