@windrun-huaiin/backend-core 15.1.0 → 16.0.0
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 +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -0
- package/dist/index.mjs +8 -1
- package/dist/lib/index.js +19 -0
- package/dist/lib/index.mjs +1 -1
- package/dist/lib/upstash/qstash.d.ts +20 -7
- package/dist/lib/upstash/qstash.d.ts.map +1 -1
- package/dist/lib/upstash/qstash.js +33 -7
- package/dist/lib/upstash/qstash.mjs +33 -7
- package/dist/lib/upstash/redis-structures.d.ts +83 -0
- package/dist/lib/upstash/redis-structures.d.ts.map +1 -1
- package/dist/lib/upstash/redis-structures.js +220 -0
- package/dist/lib/upstash/redis-structures.mjs +202 -1
- package/dist/lib/upstash-config.d.ts.map +1 -1
- package/dist/lib/upstash-config.js +76 -4
- package/dist/lib/upstash-config.mjs +76 -4
- package/dist/services/ai/abort.d.ts +2 -0
- package/dist/services/ai/abort.d.ts.map +1 -0
- package/dist/services/ai/abort.js +24 -0
- package/dist/services/ai/abort.mjs +22 -0
- package/dist/services/ai/env.d.ts +21 -0
- package/dist/services/ai/env.d.ts.map +1 -0
- package/dist/services/ai/env.js +85 -0
- package/dist/services/ai/env.mjs +80 -0
- package/dist/services/ai/error.d.ts +3 -0
- package/dist/services/ai/error.d.ts.map +1 -0
- package/dist/services/ai/error.js +54 -0
- package/dist/services/ai/error.mjs +52 -0
- package/dist/services/ai/index.d.ts +9 -0
- package/dist/services/ai/index.d.ts.map +1 -0
- package/dist/services/ai/index.js +30 -0
- package/dist/services/ai/index.mjs +7 -0
- package/dist/services/ai/message-builder.d.ts +4 -0
- package/dist/services/ai/message-builder.d.ts.map +1 -0
- package/dist/services/ai/message-builder.js +15 -0
- package/dist/services/ai/message-builder.mjs +13 -0
- package/dist/services/ai/mock.d.ts +30 -0
- package/dist/services/ai/mock.d.ts.map +1 -0
- package/dist/services/ai/mock.js +314 -0
- package/dist/services/ai/mock.mjs +308 -0
- package/dist/services/ai/openrouter-client.d.ts +12 -0
- package/dist/services/ai/openrouter-client.d.ts.map +1 -0
- package/dist/services/ai/openrouter-client.js +81 -0
- package/dist/services/ai/openrouter-client.mjs +78 -0
- package/dist/services/ai/route.d.ts +6 -0
- package/dist/services/ai/route.d.ts.map +1 -0
- package/dist/services/ai/route.js +178 -0
- package/dist/services/ai/route.mjs +173 -0
- package/dist/services/ai/types.d.ts +98 -0
- package/dist/services/ai/types.d.ts.map +1 -0
- package/package.json +11 -4
- package/src/index.ts +1 -0
- package/src/lib/upstash/qstash.ts +55 -15
- package/src/lib/upstash/redis-structures.ts +248 -0
- package/src/lib/upstash-config.ts +106 -4
- package/src/services/ai/abort.ts +26 -0
- package/src/services/ai/env.ts +120 -0
- package/src/services/ai/error.ts +64 -0
- package/src/services/ai/index.ts +8 -0
- package/src/services/ai/message-builder.ts +17 -0
- package/src/services/ai/mock.ts +378 -0
- package/src/services/ai/openrouter-client.ts +94 -0
- package/src/services/ai/route.ts +218 -0
- package/src/services/ai/types.ts +131 -0
package/src/index.ts
CHANGED
|
@@ -46,63 +46,103 @@ const getReceiver = (): Receiver | null => {
|
|
|
46
46
|
|
|
47
47
|
export type PublishBody = Record<string, unknown> | string | number | boolean | null;
|
|
48
48
|
|
|
49
|
-
export interface
|
|
49
|
+
export interface QstashEnvelope<TBody extends PublishBody = PublishBody> {
|
|
50
|
+
source_msg_id: string;
|
|
51
|
+
payload: TBody;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PublishMessageOptions<TBody extends PublishBody = PublishBody> {
|
|
50
55
|
url: string;
|
|
51
|
-
body:
|
|
56
|
+
body: TBody;
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
const generateSourceMessageId = (): string => {
|
|
60
|
+
try {
|
|
61
|
+
return crypto.randomUUID();
|
|
62
|
+
} catch {
|
|
63
|
+
return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const createEnvelope = <TBody extends PublishBody>(body: TBody): QstashEnvelope<TBody> => {
|
|
68
|
+
return {
|
|
69
|
+
source_msg_id: generateSourceMessageId(),
|
|
70
|
+
payload: body,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
54
74
|
/**
|
|
55
75
|
* Publish a message. Returns message id or null if QStash is unavailable.
|
|
56
76
|
*/
|
|
57
|
-
export const publishMessage = async
|
|
77
|
+
export const publishMessage = async <TBody extends PublishBody>(
|
|
78
|
+
options: PublishMessageOptions<TBody>
|
|
79
|
+
): Promise<{ messageId: string | null; message: QstashEnvelope<TBody> } | null> => {
|
|
80
|
+
const message = createEnvelope(options.body);
|
|
81
|
+
|
|
58
82
|
return withQstash(async (client) => {
|
|
59
83
|
const result = await (client as any).publishJSON({
|
|
60
84
|
url: options.url,
|
|
61
|
-
body:
|
|
85
|
+
body: message,
|
|
62
86
|
});
|
|
63
|
-
return
|
|
87
|
+
return {
|
|
88
|
+
messageId: typeof result === 'string' ? result : result?.messageId ?? null,
|
|
89
|
+
message,
|
|
90
|
+
};
|
|
64
91
|
});
|
|
65
92
|
};
|
|
66
93
|
|
|
67
94
|
/**
|
|
68
95
|
* Publish a delayed message. Returns message id or null if QStash is unavailable.
|
|
69
96
|
*/
|
|
70
|
-
export const publishDelayedMessage = async (
|
|
71
|
-
options: PublishMessageOptions & { delaySec: number }
|
|
72
|
-
): Promise<string | null> => {
|
|
97
|
+
export const publishDelayedMessage = async <TBody extends PublishBody>(
|
|
98
|
+
options: PublishMessageOptions<TBody> & { delaySec: number }
|
|
99
|
+
): Promise<{ messageId: string | null; message: QstashEnvelope<TBody> } | null> => {
|
|
100
|
+
const message = createEnvelope(options.body);
|
|
101
|
+
|
|
73
102
|
return withQstash(async (client) => {
|
|
74
103
|
const result = await (client as any).publishJSON({
|
|
75
104
|
url: options.url,
|
|
76
|
-
body:
|
|
105
|
+
body: message,
|
|
77
106
|
delay: options.delaySec,
|
|
78
107
|
});
|
|
79
|
-
return
|
|
108
|
+
return {
|
|
109
|
+
messageId: typeof result === 'string' ? result : result?.messageId ?? null,
|
|
110
|
+
message,
|
|
111
|
+
};
|
|
80
112
|
});
|
|
81
113
|
};
|
|
82
114
|
|
|
83
|
-
export interface ScheduleMessageOptions extends
|
|
115
|
+
export interface ScheduleMessageOptions<TBody extends PublishBody = PublishBody>
|
|
116
|
+
extends PublishMessageOptions<TBody> {
|
|
84
117
|
cron: string;
|
|
85
118
|
}
|
|
86
119
|
|
|
87
120
|
/**
|
|
88
121
|
* Schedule a recurring message. Returns schedule id or null if QStash is unavailable.
|
|
89
122
|
*/
|
|
90
|
-
export const scheduleMessage = async
|
|
123
|
+
export const scheduleMessage = async <TBody extends PublishBody>(
|
|
124
|
+
options: ScheduleMessageOptions<TBody>
|
|
125
|
+
): Promise<{ scheduleId: string | null; message: QstashEnvelope<TBody> } | null> => {
|
|
126
|
+
const message = createEnvelope(options.body);
|
|
127
|
+
|
|
91
128
|
return withQstash(async (client) => {
|
|
92
129
|
const anyClient = client as any;
|
|
93
130
|
const result =
|
|
94
131
|
(await anyClient.schedules?.create?.({
|
|
95
132
|
url: options.url,
|
|
96
|
-
body:
|
|
133
|
+
body: message,
|
|
97
134
|
cron: options.cron,
|
|
98
135
|
})) ??
|
|
99
136
|
(await anyClient.publishJSON?.({
|
|
100
137
|
url: options.url,
|
|
101
|
-
body:
|
|
138
|
+
body: message,
|
|
102
139
|
cron: options.cron,
|
|
103
140
|
}));
|
|
104
141
|
|
|
105
|
-
return
|
|
142
|
+
return {
|
|
143
|
+
scheduleId: typeof result === 'string' ? result : result?.scheduleId ?? result?.id ?? null,
|
|
144
|
+
message,
|
|
145
|
+
};
|
|
106
146
|
});
|
|
107
147
|
};
|
|
108
148
|
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import type { Redis } from '@upstash/redis';
|
|
2
|
+
|
|
1
3
|
import { withRedis } from '../upstash-config';
|
|
2
4
|
|
|
5
|
+
export type RedisStringKeyValue = Record<string, string>;
|
|
6
|
+
export type RedisJsonKeyValue<T> = Record<string, T>;
|
|
7
|
+
export type RedisHashStringValue = Record<string, string>;
|
|
8
|
+
export type RedisPipelineBuilder<TResult> = (pipeline: ReturnType<Redis['pipeline']>) => {
|
|
9
|
+
exec: () => Promise<TResult>;
|
|
10
|
+
};
|
|
11
|
+
|
|
3
12
|
/**
|
|
4
13
|
* Set a plain string value with optional TTL (seconds).
|
|
5
14
|
*/
|
|
@@ -26,6 +35,34 @@ export const getString = async (key: string): Promise<string | null> => {
|
|
|
26
35
|
return withRedis((redis) => redis.get<string>(key));
|
|
27
36
|
};
|
|
28
37
|
|
|
38
|
+
/**
|
|
39
|
+
* MGET plain string values. Missing keys are returned as null.
|
|
40
|
+
*/
|
|
41
|
+
export const mget = async (keys: string[]): Promise<(string | null)[] | null> => {
|
|
42
|
+
return withRedis((redis) => {
|
|
43
|
+
if (keys.length === 0) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return redis.mget<(string | null)[]>(...keys);
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* MSET plain string values.
|
|
53
|
+
*/
|
|
54
|
+
export const mset = async (entries: RedisStringKeyValue): Promise<boolean> => {
|
|
55
|
+
const keys = Object.keys(entries);
|
|
56
|
+
if (keys.length === 0) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return withRedis(async (redis) => {
|
|
61
|
+
await redis.mset(entries);
|
|
62
|
+
return true;
|
|
63
|
+
}).then((result) => result ?? false);
|
|
64
|
+
};
|
|
65
|
+
|
|
29
66
|
/**
|
|
30
67
|
* Store an object as JSON string with optional TTL (seconds).
|
|
31
68
|
*/
|
|
@@ -64,6 +101,49 @@ export const getJson = async <T>(key: string): Promise<T | null> => {
|
|
|
64
101
|
});
|
|
65
102
|
};
|
|
66
103
|
|
|
104
|
+
/**
|
|
105
|
+
* MGET JSON values stored as strings. Missing or invalid values are returned as null.
|
|
106
|
+
*/
|
|
107
|
+
export const mgetJson = async <T>(keys: string[]): Promise<(T | null)[] | null> => {
|
|
108
|
+
return withRedis(async (redis) => {
|
|
109
|
+
if (keys.length === 0) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const payloads = await redis.mget<(string | null)[]>(...keys);
|
|
114
|
+
return payloads.map((payload) => {
|
|
115
|
+
if (!payload) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(payload) as T;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* MSET JSON values as strings.
|
|
130
|
+
*/
|
|
131
|
+
export const msetJson = async <T>(entries: RedisJsonKeyValue<T>): Promise<boolean> => {
|
|
132
|
+
const keys = Object.keys(entries);
|
|
133
|
+
if (keys.length === 0) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const payloads = Object.fromEntries(
|
|
138
|
+
Object.entries(entries).map(([key, value]) => [key, JSON.stringify(value)])
|
|
139
|
+
) as RedisStringKeyValue;
|
|
140
|
+
|
|
141
|
+
return withRedis(async (redis) => {
|
|
142
|
+
await redis.mset(payloads);
|
|
143
|
+
return true;
|
|
144
|
+
}).then((result) => result ?? false);
|
|
145
|
+
};
|
|
146
|
+
|
|
67
147
|
/**
|
|
68
148
|
* Delete a key. Returns false if Redis is unavailable.
|
|
69
149
|
*/
|
|
@@ -75,6 +155,52 @@ export const deleteKey = async (key: string): Promise<boolean> => {
|
|
|
75
155
|
return result ?? false;
|
|
76
156
|
};
|
|
77
157
|
|
|
158
|
+
/**
|
|
159
|
+
* DEL multiple keys. Returns deleted count, or null if Redis is unavailable.
|
|
160
|
+
*/
|
|
161
|
+
export const del = async (keys: string[]): Promise<number | null> => {
|
|
162
|
+
return withRedis((redis) => {
|
|
163
|
+
if (keys.length === 0) {
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return redis.del(...keys);
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* EXISTS a key.
|
|
173
|
+
*/
|
|
174
|
+
export const exists = async (key: string): Promise<boolean | null> => {
|
|
175
|
+
return withRedis(async (redis) => {
|
|
176
|
+
const count = await redis.exists(key);
|
|
177
|
+
return count > 0;
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* EXPIRE a key in seconds.
|
|
183
|
+
*/
|
|
184
|
+
export const expire = async (key: string, ttlSec: number): Promise<boolean> => {
|
|
185
|
+
if (ttlSec <= 0) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const result = await withRedis(async (redis) => {
|
|
190
|
+
const changed = await redis.expire(key, ttlSec);
|
|
191
|
+
return changed > 0;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return result ?? false;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* TTL for a key in seconds. Returns null if Redis is unavailable.
|
|
199
|
+
*/
|
|
200
|
+
export const ttl = async (key: string): Promise<number | null> => {
|
|
201
|
+
return withRedis((redis) => redis.ttl(key));
|
|
202
|
+
};
|
|
203
|
+
|
|
78
204
|
/**
|
|
79
205
|
* Set a hash field value.
|
|
80
206
|
*/
|
|
@@ -93,6 +219,42 @@ export const getHashField = async (key: string, field: string): Promise<string |
|
|
|
93
219
|
return withRedis((redis) => redis.hget<string>(key, field));
|
|
94
220
|
};
|
|
95
221
|
|
|
222
|
+
/**
|
|
223
|
+
* HMSET hash fields.
|
|
224
|
+
*/
|
|
225
|
+
export const hmset = async (key: string, values: RedisHashStringValue): Promise<boolean> => {
|
|
226
|
+
const fields = Object.keys(values);
|
|
227
|
+
if (fields.length === 0) {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const result = await withRedis(async (redis) => {
|
|
232
|
+
await redis.hset(key, values);
|
|
233
|
+
return true;
|
|
234
|
+
});
|
|
235
|
+
return result ?? false;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* HMGET hash fields.
|
|
240
|
+
*/
|
|
241
|
+
export const hmget = async (
|
|
242
|
+
key: string,
|
|
243
|
+
fields: string[]
|
|
244
|
+
): Promise<Record<string, string | null> | null> => {
|
|
245
|
+
return withRedis(async (redis) => {
|
|
246
|
+
if (fields.length === 0) {
|
|
247
|
+
return {};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const result = await redis.hmget<Record<string, string | null>>(key, ...fields);
|
|
251
|
+
if (!result) {
|
|
252
|
+
return Object.fromEntries(fields.map((field) => [field, null]));
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
|
|
96
258
|
/**
|
|
97
259
|
* Store a hash field as JSON string.
|
|
98
260
|
*/
|
|
@@ -137,6 +299,30 @@ export const getHashAll = async (key: string): Promise<Record<string, string> |
|
|
|
137
299
|
});
|
|
138
300
|
};
|
|
139
301
|
|
|
302
|
+
/**
|
|
303
|
+
* HEXISTS a hash field.
|
|
304
|
+
*/
|
|
305
|
+
export const hexists = async (key: string, field: string): Promise<boolean | null> => {
|
|
306
|
+
return withRedis(async (redis) => {
|
|
307
|
+
const exists = await redis.hexists(key, field);
|
|
308
|
+
return exists > 0;
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* HKEYS for a hash.
|
|
314
|
+
*/
|
|
315
|
+
export const hkeys = async (key: string): Promise<string[] | null> => {
|
|
316
|
+
return withRedis((redis) => redis.hkeys(key));
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* HLEN for a hash.
|
|
321
|
+
*/
|
|
322
|
+
export const hlen = async (key: string): Promise<number | null> => {
|
|
323
|
+
return withRedis((redis) => redis.hlen(key));
|
|
324
|
+
};
|
|
325
|
+
|
|
140
326
|
/**
|
|
141
327
|
* Remove a hash field.
|
|
142
328
|
*/
|
|
@@ -148,6 +334,56 @@ export const deleteHashField = async (key: string, field: string): Promise<boole
|
|
|
148
334
|
return result ?? false;
|
|
149
335
|
};
|
|
150
336
|
|
|
337
|
+
/**
|
|
338
|
+
* SADD members to a set. Returns count of newly added members, or null if Redis is unavailable.
|
|
339
|
+
*/
|
|
340
|
+
export const sadd = async (key: string, members: string[]): Promise<number | null> => {
|
|
341
|
+
return withRedis((redis) => {
|
|
342
|
+
if (members.length === 0) {
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return redis.sadd(key, members[0], ...members.slice(1));
|
|
347
|
+
});
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* SREM members from a set. Returns count of removed members, or null if Redis is unavailable.
|
|
352
|
+
*/
|
|
353
|
+
export const srem = async (key: string, members: string[]): Promise<number | null> => {
|
|
354
|
+
return withRedis((redis) => {
|
|
355
|
+
if (members.length === 0) {
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return redis.srem(key, ...members);
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* SISMEMBER for a set member.
|
|
365
|
+
*/
|
|
366
|
+
export const sismember = async (key: string, member: string): Promise<boolean | null> => {
|
|
367
|
+
return withRedis(async (redis) => {
|
|
368
|
+
const exists = await redis.sismember(key, member);
|
|
369
|
+
return exists > 0;
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* SMEMBERS for a set.
|
|
375
|
+
*/
|
|
376
|
+
export const smembers = async (key: string): Promise<string[] | null> => {
|
|
377
|
+
return withRedis((redis) => redis.smembers<string[]>(key));
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* SCARD for a set.
|
|
382
|
+
*/
|
|
383
|
+
export const scard = async (key: string): Promise<number | null> => {
|
|
384
|
+
return withRedis((redis) => redis.scard(key));
|
|
385
|
+
};
|
|
386
|
+
|
|
151
387
|
type ListDirection = 'left' | 'right';
|
|
152
388
|
|
|
153
389
|
/**
|
|
@@ -198,3 +434,15 @@ export const rangeList = async (
|
|
|
198
434
|
export const listLength = async (key: string): Promise<number | null> => {
|
|
199
435
|
return withRedis((redis) => redis.llen(key));
|
|
200
436
|
};
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Execute a Redis pipeline and return the result array from exec().
|
|
440
|
+
*/
|
|
441
|
+
export const pipeline = async <TResult>(
|
|
442
|
+
build: RedisPipelineBuilder<TResult>
|
|
443
|
+
): Promise<TResult | null> => {
|
|
444
|
+
return withRedis(async (redis) => {
|
|
445
|
+
const pipeline = redis.pipeline();
|
|
446
|
+
return build(pipeline).exec();
|
|
447
|
+
});
|
|
448
|
+
};
|
|
@@ -19,6 +19,7 @@ let qstashWarnedHealthSchedule = false;
|
|
|
19
19
|
|
|
20
20
|
let redisHealthTimer: ReturnType<typeof setTimeout> | null = null;
|
|
21
21
|
let qstashHealthTimer: ReturnType<typeof setTimeout> | null = null;
|
|
22
|
+
let cachedRedisPrefixed: Redis | null = null;
|
|
22
23
|
|
|
23
24
|
const isNonEmpty = (value: string | undefined): value is string =>
|
|
24
25
|
typeof value === 'string' && value.trim().length > 0;
|
|
@@ -32,6 +33,105 @@ const isValidUrl = (value: string): boolean => {
|
|
|
32
33
|
}
|
|
33
34
|
};
|
|
34
35
|
|
|
36
|
+
const getRequiredRedisAppName = (): string => {
|
|
37
|
+
const appName = process.env.NEXT_PUBLIC_APP_NAME;
|
|
38
|
+
if (!isNonEmpty(appName)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'[Upstash Config] NEXT_PUBLIC_APP_NAME is required for Redis key prefixing and must not be empty'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const normalized = appName.replace(/\s+/g, '').toLowerCase();
|
|
45
|
+
if (!normalized) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'[Upstash Config] NEXT_PUBLIC_APP_NAME must contain non-whitespace characters for Redis key prefixing'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return normalized;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const getRedisKeyPrefix = (): string => {
|
|
55
|
+
const envSuffix = process.env.NODE_ENV === 'production' ? 'live' : 'test';
|
|
56
|
+
return `${getRequiredRedisAppName()}_${envSuffix}`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const prefixRedisKey = (prefix: string, key: string): string => `${prefix}:${key}`;
|
|
60
|
+
|
|
61
|
+
const prefixRedisKeys = (prefix: string, keys: string[]): string[] =>
|
|
62
|
+
keys.map((key) => prefixRedisKey(prefix, key));
|
|
63
|
+
|
|
64
|
+
const prefixFirstStringArg = (args: unknown[], prefix: string): unknown[] => {
|
|
65
|
+
if (typeof args[0] !== 'string') {
|
|
66
|
+
return args;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const nextArgs = [...args];
|
|
70
|
+
nextArgs[0] = prefixRedisKey(prefix, args[0]);
|
|
71
|
+
return nextArgs;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const prefixAllStringArgs = (args: unknown[], prefix: string): unknown[] => {
|
|
75
|
+
return args.map((arg) => (typeof arg === 'string' ? prefixRedisKey(prefix, arg) : arg));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const keyArrayCommands = new Set(['mget', 'del']);
|
|
79
|
+
const allStringKeyCommands = new Set(['exists']);
|
|
80
|
+
|
|
81
|
+
const createPrefixedPipeline = <T extends object>(target: T, prefix: string): T => {
|
|
82
|
+
return new Proxy(target, {
|
|
83
|
+
get(obj, prop, receiver) {
|
|
84
|
+
const value = Reflect.get(obj, prop, receiver);
|
|
85
|
+
if (typeof value !== 'function') {
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (...args: unknown[]) => {
|
|
90
|
+
if (prop === 'eval' || prop === 'evalsha' || prop === 'evalro' || prop === 'evalshaRo') {
|
|
91
|
+
const [script, keys, argv] = args as [string, string[], unknown[]];
|
|
92
|
+
return (value as (...innerArgs: unknown[]) => unknown).call(
|
|
93
|
+
obj,
|
|
94
|
+
script,
|
|
95
|
+
prefixRedisKeys(prefix, keys),
|
|
96
|
+
argv
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (prop === 'pipeline' || prop === 'multi') {
|
|
101
|
+
const nested = (value as (...innerArgs: unknown[]) => unknown).call(obj);
|
|
102
|
+
return createPrefixedPipeline(nested as T, prefix);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof prop === 'string' && keyArrayCommands.has(prop)) {
|
|
106
|
+
const nextArgs = prefixAllStringArgs(args, prefix);
|
|
107
|
+
return (value as (...innerArgs: unknown[]) => unknown).apply(obj, nextArgs);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof prop === 'string' && allStringKeyCommands.has(prop)) {
|
|
111
|
+
const nextArgs = prefixAllStringArgs(args, prefix);
|
|
112
|
+
return (value as (...innerArgs: unknown[]) => unknown).apply(obj, nextArgs);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (prop === 'mset') {
|
|
116
|
+
const [entries] = args as [Record<string, unknown>];
|
|
117
|
+
const prefixedEntries = Object.fromEntries(
|
|
118
|
+
Object.entries(entries).map(([key, entryValue]) => [prefixRedisKey(prefix, key), entryValue])
|
|
119
|
+
);
|
|
120
|
+
return (value as (...innerArgs: unknown[]) => unknown).call(obj, prefixedEntries);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (prop === 'hmget') {
|
|
124
|
+
const nextArgs = prefixFirstStringArg(args, prefix);
|
|
125
|
+
return (value as (...innerArgs: unknown[]) => unknown).apply(obj, nextArgs);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const nextArgs = prefixFirstStringArg(args, prefix);
|
|
129
|
+
return (value as (...innerArgs: unknown[]) => unknown).apply(obj, nextArgs);
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
35
135
|
const parseMinutes = (value: string | undefined, fallback: number): number => {
|
|
36
136
|
if (!isNonEmpty(value)) {
|
|
37
137
|
return fallback;
|
|
@@ -138,12 +238,12 @@ const scheduleQstashHealthCheck = (token: string): void => {
|
|
|
138
238
|
* - read-through cached instance only
|
|
139
239
|
*/
|
|
140
240
|
export const getRedis = (): Redis | null => {
|
|
141
|
-
return
|
|
241
|
+
return cachedRedisPrefixed;
|
|
142
242
|
};
|
|
143
243
|
|
|
144
244
|
const ensureRedis = async (): Promise<Redis | null> => {
|
|
145
|
-
if (
|
|
146
|
-
return
|
|
245
|
+
if (cachedRedisPrefixed) {
|
|
246
|
+
return cachedRedisPrefixed;
|
|
147
247
|
}
|
|
148
248
|
if (redisInitPromise) {
|
|
149
249
|
return redisInitPromise;
|
|
@@ -170,14 +270,16 @@ const ensureRedis = async (): Promise<Redis | null> => {
|
|
|
170
270
|
}
|
|
171
271
|
|
|
172
272
|
try {
|
|
273
|
+
const keyPrefix = getRedisKeyPrefix();
|
|
173
274
|
const client = new Redis({
|
|
174
275
|
url: UPSTASH_REDIS_REST_URL,
|
|
175
276
|
token: UPSTASH_REDIS_REST_TOKEN,
|
|
176
277
|
});
|
|
177
278
|
await client.ping();
|
|
178
279
|
cachedRedis = client;
|
|
280
|
+
cachedRedisPrefixed = createPrefixedPipeline(client, keyPrefix) as Redis;
|
|
179
281
|
scheduleRedisHealthCheck();
|
|
180
|
-
return
|
|
282
|
+
return cachedRedisPrefixed;
|
|
181
283
|
} catch (error) {
|
|
182
284
|
if (!redisWarnedInitError) {
|
|
183
285
|
redisWarnedInitError = true;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function createUpstreamAbortSignal(requestSignal: AbortSignal, timeoutMs: number) {
|
|
2
|
+
const controller = new AbortController();
|
|
3
|
+
const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs);
|
|
4
|
+
|
|
5
|
+
const forwardAbort = () => {
|
|
6
|
+
clearTimeout(timeoutId);
|
|
7
|
+
controller.abort(requestSignal.reason ?? 'request_aborted');
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
if (requestSignal.aborted) {
|
|
11
|
+
forwardAbort();
|
|
12
|
+
} else {
|
|
13
|
+
requestSignal.addEventListener('abort', forwardAbort, { once: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
controller.signal.addEventListener(
|
|
17
|
+
'abort',
|
|
18
|
+
() => {
|
|
19
|
+
clearTimeout(timeoutId);
|
|
20
|
+
requestSignal.removeEventListener('abort', forwardAbort);
|
|
21
|
+
},
|
|
22
|
+
{ once: true },
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return controller.signal;
|
|
26
|
+
}
|