@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.
Files changed (75) hide show
  1. package/README.md +37 -2
  2. package/cloudflare/access.ts +134 -0
  3. package/cloudflare/amqp/client.ts +276 -0
  4. package/cloudflare/amqp/testing.ts +9 -0
  5. package/cloudflare/context.ts +66 -0
  6. package/cloudflare/dispatch/client.ts +30 -0
  7. package/cloudflare/email/client.ts +77 -0
  8. package/cloudflare/email/dev.ts +10 -0
  9. package/cloudflare/email/testing.ts +13 -0
  10. package/cloudflare/r2/client.ts +154 -0
  11. package/cloudflare/r2/testing.ts +43 -0
  12. package/cloudflare/rate-limiter.ts +65 -0
  13. package/cloudflare/wait-until.ts +12 -0
  14. package/cloudflare/workers/index.ts +311 -0
  15. package/cloudflare/workers/testing.ts +94 -0
  16. package/cloudflare/workers/ticking-do.ts +104 -0
  17. package/cloudflare/workers-ai/client.ts +30 -0
  18. package/cloudflare/workers-ai/testing.ts +17 -0
  19. package/cloudflare/workers-env.ts +12 -0
  20. package/eslint-config.js +115 -0
  21. package/monitoring/alerts/client.ts +48 -0
  22. package/monitoring/alerts/event-config.ts +33 -0
  23. package/monitoring/alerts/policy.ts +39 -0
  24. package/monitoring/alerts/registry.ts +44 -0
  25. package/monitoring/gates/client.ts +90 -0
  26. package/monitoring/gates/dev.ts +29 -0
  27. package/monitoring/gates/testing.ts +30 -0
  28. package/monitoring/logger/client.ts +66 -0
  29. package/monitoring/prometheus/client.ts +147 -0
  30. package/monitoring/prometheus/dev.ts +23 -0
  31. package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
  32. package/monitoring/prometheus/metrics.ts +180 -0
  33. package/monitoring/prometheus/testing.ts +19 -0
  34. package/monitoring/slack/api.ts +68 -0
  35. package/monitoring/slack/client.ts +33 -0
  36. package/monitoring/slack/events.ts +15 -0
  37. package/package.json +150 -5
  38. package/server/logger.ts +63 -0
  39. package/server/rate-limit-client.ts +88 -0
  40. package/server/rate-limit.ts +24 -0
  41. package/server/remote-functions.ts +139 -0
  42. package/server/result.ts +18 -0
  43. package/server/turnstile.ts +19 -0
  44. package/storage/clickhouse/client.ts +441 -0
  45. package/storage/clickhouse/compiler.ts +218 -0
  46. package/storage/clickhouse/config.ts +13 -0
  47. package/storage/clickhouse/dev.ts +18 -0
  48. package/storage/clickhouse/error.ts +61 -0
  49. package/storage/clickhouse/index.ts +4 -0
  50. package/storage/clickhouse/native-decoder.ts +192 -0
  51. package/storage/clickhouse/readers.ts +167 -0
  52. package/storage/clickhouse/retry.ts +30 -0
  53. package/storage/clickhouse/schema.ts +163 -0
  54. package/storage/clickhouse/testing.ts +81 -0
  55. package/storage/manticore/client.ts +165 -0
  56. package/storage/manticore/schema.ts +23 -0
  57. package/storage/postgres/client.ts +44 -0
  58. package/storage/postgres/testing.ts +275 -0
  59. package/storage/redis/client.ts +219 -0
  60. package/storage/redis/protocol.ts +119 -0
  61. package/storage/redis/schema.ts +17 -0
  62. package/storage/redis/testing.ts +122 -0
  63. package/utils/crypto.ts +36 -0
  64. package/utils/datetime.ts +338 -0
  65. package/utils/email.ts +173 -0
  66. package/utils/env.ts +171 -0
  67. package/utils/errors.ts +67 -0
  68. package/utils/form.ts +135 -0
  69. package/utils/http-status.ts +42 -0
  70. package/utils/money.ts +10 -0
  71. package/utils/public-host.ts +66 -0
  72. package/utils/schema.ts +211 -0
  73. package/utils/strings/format.ts +51 -0
  74. package/utils/strings/normalize.ts +129 -0
  75. 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
+ }
@@ -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
+ };