mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-19 02:33:43 +08:00
295 lines
7.8 KiB
TypeScript
295 lines
7.8 KiB
TypeScript
import 'server-only';
|
|
|
|
import { Redis } from '@upstash/redis';
|
|
|
|
type RedisPrimitive = string | number | null;
|
|
type RedisRawValue = RedisPrimitive | RedisRawValue[];
|
|
|
|
export interface RedisClient {
|
|
get<T = unknown>(key: string): Promise<T | null>;
|
|
set(key: string, value: unknown): Promise<unknown>;
|
|
del(key: string): Promise<unknown>;
|
|
incr(key: string): Promise<number>;
|
|
expire(key: string, seconds: number): Promise<unknown>;
|
|
ttl(key: string): Promise<number>;
|
|
}
|
|
|
|
class RedisProtocolError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'RedisProtocolError';
|
|
}
|
|
}
|
|
|
|
class TcpRedisClient implements RedisClient {
|
|
private readonly url: URL;
|
|
|
|
constructor(redisUrl: string) {
|
|
this.url = new URL(redisUrl);
|
|
}
|
|
|
|
async get<T = unknown>(key: string): Promise<T | null> {
|
|
const value = await this.command(['GET', key]);
|
|
if (value === null) return null;
|
|
if (typeof value !== 'string') return value as T;
|
|
|
|
try {
|
|
return JSON.parse(value) as T;
|
|
} catch {
|
|
return value as T;
|
|
}
|
|
}
|
|
|
|
async set(key: string, value: unknown): Promise<unknown> {
|
|
return this.command(['SET', key, JSON.stringify(value)]);
|
|
}
|
|
|
|
async del(key: string): Promise<unknown> {
|
|
return this.command(['DEL', key]);
|
|
}
|
|
|
|
async incr(key: string): Promise<number> {
|
|
const value = await this.command(['INCR', key]);
|
|
return typeof value === 'number' ? value : Number(value);
|
|
}
|
|
|
|
async expire(key: string, seconds: number): Promise<unknown> {
|
|
return this.command(['EXPIRE', key, String(seconds)]);
|
|
}
|
|
|
|
async ttl(key: string): Promise<number> {
|
|
const value = await this.command(['TTL', key]);
|
|
return typeof value === 'number' ? value : Number(value);
|
|
}
|
|
|
|
private async command(args: string[]): Promise<RedisRawValue> {
|
|
const setupCommands: string[][] = [];
|
|
const username = decodeUrlPart(this.url.username);
|
|
const password = decodeUrlPart(this.url.password);
|
|
const database = this.url.pathname.replace(/^\//, '');
|
|
|
|
if (password) {
|
|
setupCommands.push(username ? ['AUTH', username, password] : ['AUTH', password]);
|
|
}
|
|
|
|
if (database) {
|
|
setupCommands.push(['SELECT', database]);
|
|
}
|
|
|
|
const responses = await sendRedisCommands(this.url, [
|
|
...setupCommands,
|
|
args,
|
|
['QUIT'],
|
|
]);
|
|
|
|
return responses[setupCommands.length] ?? null;
|
|
}
|
|
}
|
|
|
|
let cachedRedis: RedisClient | null | undefined;
|
|
|
|
function decodeUrlPart(value: string): string {
|
|
if (!value) return '';
|
|
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function isManagedPlatform(): boolean {
|
|
return process.env.VERCEL === '1' ||
|
|
Boolean(process.env.VERCEL_ENV) ||
|
|
process.env.CF_PAGES === '1' ||
|
|
Boolean(process.env.CF_PAGES_URL) ||
|
|
Boolean(process.env.CLOUDFLARE_ACCOUNT_ID) ||
|
|
Boolean(process.env.CF_ACCOUNT_ID) ||
|
|
Boolean(process.env.WORKERS_CI);
|
|
}
|
|
|
|
function getTcpRedisUrl(): string {
|
|
if (isManagedPlatform()) return '';
|
|
|
|
return (
|
|
process.env.KVIDEO_REDIS_URL?.trim() ||
|
|
process.env.REDIS_URL?.trim() ||
|
|
''
|
|
);
|
|
}
|
|
|
|
function encodeCommand(args: string[]): Buffer {
|
|
const parts = [`*${args.length}\r\n`];
|
|
|
|
for (const arg of args) {
|
|
parts.push(`$${Buffer.byteLength(arg)}\r\n${arg}\r\n`);
|
|
}
|
|
|
|
return Buffer.from(parts.join(''));
|
|
}
|
|
|
|
function readLine(buffer: Buffer, offset: number): { line: string; offset: number } | null {
|
|
const end = buffer.indexOf('\r\n', offset);
|
|
if (end === -1) return null;
|
|
|
|
return {
|
|
line: buffer.toString('utf8', offset, end),
|
|
offset: end + 2,
|
|
};
|
|
}
|
|
|
|
function parseResp(buffer: Buffer, offset: number = 0): { value: RedisRawValue; offset: number } | null {
|
|
if (offset >= buffer.length) return null;
|
|
|
|
const prefix = String.fromCharCode(buffer[offset]);
|
|
const line = readLine(buffer, offset + 1);
|
|
if (!line) return null;
|
|
|
|
switch (prefix) {
|
|
case '+':
|
|
return { value: line.line, offset: line.offset };
|
|
case '-':
|
|
throw new RedisProtocolError(line.line);
|
|
case ':':
|
|
return { value: Number(line.line), offset: line.offset };
|
|
case '$': {
|
|
const length = Number(line.line);
|
|
if (length === -1) return { value: null, offset: line.offset };
|
|
|
|
const valueStart = line.offset;
|
|
const valueEnd = valueStart + length;
|
|
const nextOffset = valueEnd + 2;
|
|
if (nextOffset > buffer.length) return null;
|
|
|
|
return {
|
|
value: buffer.toString('utf8', valueStart, valueEnd),
|
|
offset: nextOffset,
|
|
};
|
|
}
|
|
case '*': {
|
|
const length = Number(line.line);
|
|
if (length === -1) return { value: null, offset: line.offset };
|
|
|
|
const values: RedisRawValue[] = [];
|
|
let cursor = line.offset;
|
|
for (let index = 0; index < length; index += 1) {
|
|
const parsed = parseResp(buffer, cursor);
|
|
if (!parsed) return null;
|
|
|
|
values.push(parsed.value);
|
|
cursor = parsed.offset;
|
|
}
|
|
|
|
return { value: values, offset: cursor };
|
|
}
|
|
default:
|
|
throw new RedisProtocolError(`Unsupported Redis response prefix: ${prefix}`);
|
|
}
|
|
}
|
|
|
|
async function sendRedisCommands(redisUrl: URL, commands: string[][]): Promise<RedisRawValue[]> {
|
|
const useTls = redisUrl.protocol === 'rediss:';
|
|
const host = redisUrl.hostname || '127.0.0.1';
|
|
const port = Number(redisUrl.port || (useTls ? 6380 : 6379));
|
|
const timeout = Number(process.env.KVIDEO_REDIS_CONNECT_TIMEOUT_MS || 5000);
|
|
const payload = Buffer.concat(commands.map(encodeCommand));
|
|
const chunks: Buffer[] = [];
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const finish = (callback: () => void): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
callback();
|
|
};
|
|
|
|
const onReady = (): void => {
|
|
socket.write(payload);
|
|
};
|
|
|
|
let socket: import('node:net').Socket;
|
|
|
|
if (useTls) {
|
|
import('node:tls')
|
|
.then(({ connect }) => {
|
|
socket = connect({ host, port, servername: host }, onReady);
|
|
bindSocketHandlers(socket);
|
|
})
|
|
.catch((error: unknown) => finish(() => reject(error)));
|
|
} else {
|
|
import('node:net')
|
|
.then(({ connect }) => {
|
|
socket = connect({ host, port }, onReady);
|
|
bindSocketHandlers(socket);
|
|
})
|
|
.catch((error: unknown) => finish(() => reject(error)));
|
|
}
|
|
|
|
function bindSocketHandlers(activeSocket: import('node:net').Socket): void {
|
|
activeSocket.setTimeout(timeout);
|
|
|
|
activeSocket.on('data', (chunk: Buffer) => {
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
activeSocket.on('timeout', () => {
|
|
activeSocket.destroy(new Error(`Redis command timed out after ${timeout}ms`));
|
|
});
|
|
|
|
activeSocket.on('error', (error) => {
|
|
finish(() => reject(error));
|
|
});
|
|
|
|
activeSocket.on('close', (hadError) => {
|
|
if (hadError) return;
|
|
|
|
finish(() => {
|
|
try {
|
|
const buffer = Buffer.concat(chunks);
|
|
const responses: RedisRawValue[] = [];
|
|
let offset = 0;
|
|
|
|
while (offset < buffer.length) {
|
|
const parsed = parseResp(buffer, offset);
|
|
if (!parsed) {
|
|
throw new RedisProtocolError('Incomplete Redis response');
|
|
}
|
|
|
|
responses.push(parsed.value);
|
|
offset = parsed.offset;
|
|
}
|
|
|
|
resolve(responses);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
export function getRedisClient(): RedisClient | null {
|
|
if (cachedRedis !== undefined) {
|
|
return cachedRedis;
|
|
}
|
|
|
|
if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) {
|
|
cachedRedis = Redis.fromEnv() as RedisClient;
|
|
return cachedRedis;
|
|
}
|
|
|
|
const tcpRedisUrl = getTcpRedisUrl();
|
|
if (tcpRedisUrl) {
|
|
cachedRedis = new TcpRedisClient(tcpRedisUrl);
|
|
return cachedRedis;
|
|
}
|
|
|
|
cachedRedis = null;
|
|
return cachedRedis;
|
|
}
|
|
|
|
export function resetRedisClientForTests(): void {
|
|
cachedRedis = undefined;
|
|
}
|