mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
refactor(api): tidy the api and shared packages (#2733)
This commit is contained in:
+2
-6
@@ -6,12 +6,8 @@ import type {CacheLookupResult} from '@pkgs/cache/src/ICacheService';
|
||||
export function parseCachedValue<T>(value: string, logger?: CacheLogger): CacheLookupResult<T> {
|
||||
try {
|
||||
return {hit: true, value: JSON.parse(value)};
|
||||
} catch (error) {
|
||||
if (logger) {
|
||||
const truncatedValue = value.length > 200 ? `${value.substring(0, 200)}...` : value;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error({errorMessage, value: truncatedValue}, '[CacheProvider] JSON parse error');
|
||||
}
|
||||
} catch {
|
||||
logger?.error({valueLength: value.length}, '[CacheProvider] JSON parse error');
|
||||
return {hit: false};
|
||||
}
|
||||
}
|
||||
|
||||
+103
-86
@@ -8,27 +8,34 @@ import {
|
||||
} from '@pkgs/cache/src/CacheLockValidation';
|
||||
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
interface CacheEntry<T> {
|
||||
value: T;
|
||||
interface CacheEntry {
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
interface CacheValueEntry extends CacheEntry {
|
||||
kind: 'value';
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
interface CacheSetEntry extends CacheEntry {
|
||||
kind: 'set';
|
||||
value: Set<string>;
|
||||
}
|
||||
|
||||
interface CacheLockEntry extends CacheEntry {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface InMemoryProviderConfig {
|
||||
maxSize?: number;
|
||||
cleanupIntervalMs?: number;
|
||||
}
|
||||
|
||||
export class InMemoryProvider extends ICacheService {
|
||||
private cache = new Map<string, CacheEntry<unknown>>();
|
||||
private sets = new Map<string, Set<string>>();
|
||||
private locks = new Map<
|
||||
string,
|
||||
{
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
>();
|
||||
private maxSize: number;
|
||||
private readonly cache = new Map<string, CacheValueEntry | CacheSetEntry>();
|
||||
private readonly locks = new Map<string, CacheLockEntry>();
|
||||
private readonly maxSize: number;
|
||||
private cleanupInterval?: NodeJS.Timeout;
|
||||
|
||||
constructor(config: InMemoryProviderConfig = {}) {
|
||||
@@ -42,20 +49,53 @@ export class InMemoryProvider extends ICacheService {
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (entry.expiresAt && entry.expiresAt <= now) {
|
||||
if (this.isExpired(entry, now)) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, lock] of this.locks.entries()) {
|
||||
if (lock.expiresAt <= now) {
|
||||
if (this.isExpired(lock, now)) {
|
||||
this.locks.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isExpired(entry: CacheEntry<unknown>): boolean {
|
||||
if (!entry.expiresAt) return false;
|
||||
return Date.now() >= entry.expiresAt;
|
||||
private isExpired(entry: CacheEntry, now = Date.now()): boolean {
|
||||
return entry.expiresAt !== undefined && now >= entry.expiresAt;
|
||||
}
|
||||
|
||||
private getLiveEntry(key: string): CacheValueEntry | CacheSetEntry | undefined {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && this.isExpired(entry)) {
|
||||
this.cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private getValueEntry(key: string): CacheValueEntry | undefined {
|
||||
const entry = this.getLiveEntry(key);
|
||||
if (entry && entry.kind !== 'value') {
|
||||
throw new Error('Cache key contains a set, not a value.');
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private getSetEntry(key: string): CacheSetEntry | undefined {
|
||||
const entry = this.getLiveEntry(key);
|
||||
if (entry && entry.kind !== 'set') {
|
||||
throw new Error('Cache key contains a value, not a set.');
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private getLiveLock(key: string): CacheLockEntry | undefined {
|
||||
const lock = this.locks.get(key);
|
||||
if (lock && this.isExpired(lock)) {
|
||||
this.locks.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return lock;
|
||||
}
|
||||
|
||||
private evictIfNeeded(): void {
|
||||
@@ -68,18 +108,16 @@ export class InMemoryProvider extends ICacheService {
|
||||
}
|
||||
|
||||
async getEntry<T>(key: string): Promise<CacheLookupResult<T>> {
|
||||
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
|
||||
if (!entry) return {hit: false};
|
||||
if (this.isExpired(entry)) {
|
||||
this.cache.delete(key);
|
||||
return {hit: false};
|
||||
}
|
||||
return {hit: true, value: entry.value};
|
||||
const entry = this.getValueEntry(key);
|
||||
return entry ? {hit: true, value: entry.value as T} : {hit: false};
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
this.evictIfNeeded();
|
||||
const entry: CacheEntry<T> = {
|
||||
if (!this.cache.has(key)) {
|
||||
this.evictIfNeeded();
|
||||
}
|
||||
const entry: CacheValueEntry = {
|
||||
kind: 'value',
|
||||
value,
|
||||
expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined,
|
||||
};
|
||||
@@ -91,36 +129,28 @@ export class InMemoryProvider extends ICacheService {
|
||||
}
|
||||
|
||||
async getAndDelete<T>(key: string): Promise<T | null> {
|
||||
const value = await this.get<T>(key);
|
||||
if (value !== null) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
return value;
|
||||
const entry = this.getValueEntry(key);
|
||||
this.cache.delete(key);
|
||||
return entry ? (entry.value as T) : null;
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
if (this.isExpired(entry)) {
|
||||
this.cache.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return this.getLiveEntry(key) !== undefined;
|
||||
}
|
||||
|
||||
async expire(key: string, ttlSeconds: number): Promise<void> {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && !this.isExpired(entry)) {
|
||||
const entry = this.getLiveEntry(key);
|
||||
if (entry) {
|
||||
entry.expiresAt = Date.now() + ttlSeconds * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry || this.isExpired(entry)) {
|
||||
const entry = this.getLiveEntry(key);
|
||||
if (!entry) {
|
||||
return -2;
|
||||
}
|
||||
if (!entry.expiresAt) {
|
||||
if (entry.expiresAt === undefined) {
|
||||
return -1;
|
||||
}
|
||||
const ttlMs = entry.expiresAt - Date.now();
|
||||
@@ -128,11 +158,10 @@ export class InMemoryProvider extends ICacheService {
|
||||
}
|
||||
|
||||
async mget<T>(keys: Array<string>): Promise<Array<T | null>> {
|
||||
const results: Array<T | null> = [];
|
||||
for (const key of keys) {
|
||||
results.push(await this.get<T>(key));
|
||||
}
|
||||
return results;
|
||||
return keys.map((key) => {
|
||||
const entry = this.getLiveEntry(key);
|
||||
return entry?.kind === 'value' ? (entry.value as T) : null;
|
||||
});
|
||||
}
|
||||
|
||||
async mset<T>(
|
||||
@@ -162,8 +191,7 @@ export class InMemoryProvider extends ICacheService {
|
||||
async acquireLock(key: string, ttlSeconds: number): Promise<string | null> {
|
||||
validateLockKey(key);
|
||||
const lockKey = formatLockKey(key);
|
||||
const existingLock = this.locks.get(lockKey);
|
||||
if (existingLock && existingLock.expiresAt > Date.now()) {
|
||||
if (this.getLiveLock(lockKey)) {
|
||||
return null;
|
||||
}
|
||||
const token = generateLockToken();
|
||||
@@ -178,7 +206,7 @@ export class InMemoryProvider extends ICacheService {
|
||||
validateLockKey(key);
|
||||
validateLockToken(token);
|
||||
const lockKey = formatLockKey(key);
|
||||
const lock = this.locks.get(lockKey);
|
||||
const lock = this.getLiveLock(lockKey);
|
||||
if (!lock || lock.token !== token) {
|
||||
return false;
|
||||
}
|
||||
@@ -190,23 +218,21 @@ export class InMemoryProvider extends ICacheService {
|
||||
validateLockKey(key);
|
||||
validateLockToken(token);
|
||||
const lockKey = formatLockKey(key);
|
||||
const lock = this.locks.get(lockKey);
|
||||
if (!lock || lock.token !== token || lock.expiresAt <= Date.now()) {
|
||||
const lock = this.getLiveLock(lockKey);
|
||||
if (!lock || lock.token !== token) {
|
||||
return false;
|
||||
}
|
||||
this.locks.set(lockKey, {
|
||||
token,
|
||||
expiresAt: Date.now() + ttlSeconds * 1000,
|
||||
});
|
||||
lock.expiresAt = Date.now() + ttlSeconds * 1000;
|
||||
return true;
|
||||
}
|
||||
|
||||
async getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null> {
|
||||
const value = await this.get<T>(key);
|
||||
if (value !== null) {
|
||||
await this.expire(key, newTtlSeconds);
|
||||
const entry = this.getValueEntry(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
entry.expiresAt = Date.now() + newTtlSeconds * 1000;
|
||||
return entry.value as T;
|
||||
}
|
||||
|
||||
async publish(_channel: string, _message: string): Promise<void> {
|
||||
@@ -214,43 +240,35 @@ export class InMemoryProvider extends ICacheService {
|
||||
}
|
||||
|
||||
async sadd(key: string, member: string, ttlSeconds?: number): Promise<void> {
|
||||
let set = this.sets.get(key);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
this.sets.set(key, set);
|
||||
let entry = this.getSetEntry(key);
|
||||
if (!entry) {
|
||||
this.evictIfNeeded();
|
||||
entry = {kind: 'set', value: new Set<string>()};
|
||||
this.cache.set(key, entry);
|
||||
}
|
||||
set.add(member);
|
||||
entry.value.add(member);
|
||||
if (ttlSeconds) {
|
||||
await this.set(`${key}:expiry`, {}, ttlSeconds);
|
||||
entry.expiresAt = Date.now() + ttlSeconds * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
async srem(key: string, member: string): Promise<void> {
|
||||
const set = this.sets.get(key);
|
||||
if (set) {
|
||||
set.delete(member);
|
||||
if (set.size === 0) {
|
||||
this.sets.delete(key);
|
||||
}
|
||||
const entry = this.getSetEntry(key);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
entry.value.delete(member);
|
||||
if (entry.value.size === 0) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async smembers(key: string): Promise<Set<string>> {
|
||||
const expiryExists = await this.exists(`${key}:expiry`);
|
||||
if (!expiryExists && this.sets.has(key)) {
|
||||
return new Set();
|
||||
}
|
||||
return this.sets.get(key) ?? new Set<string>();
|
||||
return new Set(this.getSetEntry(key)?.value);
|
||||
}
|
||||
|
||||
async sismember(key: string, member: string): Promise<boolean> {
|
||||
const set = this.sets.get(key);
|
||||
if (!set) return false;
|
||||
const expiryExists = await this.exists(`${key}:expiry`);
|
||||
if (!expiryExists) {
|
||||
return false;
|
||||
}
|
||||
return set.has(member);
|
||||
return this.getSetEntry(key)?.value.has(member) ?? false;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
@@ -259,7 +277,6 @@ export class InMemoryProvider extends ICacheService {
|
||||
this.cleanupInterval = undefined;
|
||||
}
|
||||
this.cache.clear();
|
||||
this.sets.clear();
|
||||
this.locks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
+11
-7
@@ -10,7 +10,7 @@ import {
|
||||
import type {CacheLogger, CacheTelemetry} from '@pkgs/cache/src/CacheProviderTypes';
|
||||
import {parseCachedValue, safeJsonParse, serializeValue} from '@pkgs/cache/src/CacheSerialization';
|
||||
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {runSlotBatches, splitIntoSlotBatches} from '@pkgs/kv_client/src/KVHashSlots';
|
||||
|
||||
interface KVCacheProviderConfig {
|
||||
@@ -70,6 +70,14 @@ export class KVCacheProvider extends ICacheService {
|
||||
}
|
||||
}
|
||||
|
||||
private async executePipeline(pipeline: IKVPipeline): Promise<void> {
|
||||
for (const [error] of await pipeline.exec()) {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getEntry<T>(key: string): Promise<CacheLookupResult<T>> {
|
||||
return this.instrumented(
|
||||
'get',
|
||||
@@ -153,11 +161,7 @@ export class KVCacheProvider extends ICacheService {
|
||||
pipeline.set(entry.key, entry.value);
|
||||
}
|
||||
}
|
||||
for (const [error] of await pipeline.exec()) {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await this.executePipeline(pipeline);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,7 +208,7 @@ export class KVCacheProvider extends ICacheService {
|
||||
if (ttlSeconds) {
|
||||
pipeline.expire(key, ttlSeconds);
|
||||
}
|
||||
await pipeline.exec();
|
||||
await this.executePipeline(pipeline);
|
||||
}
|
||||
|
||||
async srem(key: string, member: string): Promise<void> {
|
||||
|
||||
@@ -20,6 +20,21 @@ interface CaptchaVerifyResponse {
|
||||
'error-codes'?: Array<string>;
|
||||
hostname?: string;
|
||||
challenge_ts?: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
function isCaptchaVerifyResponse(value: unknown): value is CaptchaVerifyResponse {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
||||
const data = value as Record<string, unknown>;
|
||||
const errorCodes = data['error-codes'];
|
||||
return (
|
||||
typeof data.success === 'boolean' &&
|
||||
(errorCodes === undefined || (Array.isArray(errorCodes) && errorCodes.every((code) => typeof code === 'string'))) &&
|
||||
(data.hostname === undefined || typeof data.hostname === 'string') &&
|
||||
(data.challenge_ts === undefined || typeof data.challenge_ts === 'string') &&
|
||||
(data.score === undefined ||
|
||||
(typeof data.score === 'number' && Number.isFinite(data.score) && data.score >= 0 && data.score <= 1))
|
||||
);
|
||||
}
|
||||
|
||||
export abstract class HttpCaptchaProvider implements ICaptchaProvider {
|
||||
@@ -58,10 +73,17 @@ export abstract class HttpCaptchaProvider implements ICaptchaProvider {
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {
|
||||
this.logger?.warn({status: response.status}, `${this.providerName} failed to cancel discarded response body`);
|
||||
});
|
||||
this.logger?.error({status: response.status}, `${this.providerName} verify request failed`);
|
||||
return false;
|
||||
}
|
||||
const data = (await response.json()) as CaptchaVerifyResponse;
|
||||
const data: unknown = await response.json();
|
||||
if (!isCaptchaVerifyResponse(data)) {
|
||||
this.logger?.error({}, `${this.providerName} returned an invalid verification response`);
|
||||
return false;
|
||||
}
|
||||
if (!data.success) {
|
||||
this.logger?.warn({errorCodes: data['error-codes']}, `${this.providerName} verification failed`);
|
||||
return false;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Client} from '@elastic/elasticsearch';
|
||||
import type {SearchRequest, SortCombinations, SortResults} from '@elastic/elasticsearch/lib/api/types';
|
||||
import type {
|
||||
BulkOperationType,
|
||||
BulkResponse,
|
||||
DeleteByQueryResponse,
|
||||
ErrorCause,
|
||||
SearchRequest,
|
||||
SortCombinations,
|
||||
SortResults,
|
||||
} from '@elastic/elasticsearch/lib/api/types';
|
||||
import type {ISearchAdapter, SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
|
||||
import {compactFilters} from '../ElasticsearchFilterUtils';
|
||||
@@ -11,6 +19,7 @@ const ELASTICSEARCH_MAX_RESULT_WINDOW = 10000;
|
||||
const DEEP_PAGINATION_BATCH_SIZE = 1000;
|
||||
const MAX_SEARCH_LIMIT = 1000;
|
||||
const FACET_TERM_LIMIT = 200;
|
||||
const ERROR_TYPE_MAX_LENGTH = 128;
|
||||
|
||||
interface ElasticsearchSearchHit<TResult> {
|
||||
_source?: TResult;
|
||||
@@ -191,7 +200,8 @@ export class ElasticsearchIndexAdapter<
|
||||
}
|
||||
this.assertInitialised();
|
||||
const operations = docs.flatMap((doc) => [{index: {_index: this.indexDefinition.indexName, _id: doc.id}}, doc]);
|
||||
await this.client.bulk({operations, refresh: false});
|
||||
const response = await this.client.bulk({operations, refresh: false});
|
||||
this.assertBulkSucceeded(response, 'index');
|
||||
}
|
||||
|
||||
async updateDocument(doc: TResult): Promise<void> {
|
||||
@@ -205,12 +215,7 @@ export class ElasticsearchIndexAdapter<
|
||||
}
|
||||
|
||||
async bulkIndexDocuments(docs: Array<TResult>): Promise<void> {
|
||||
if (docs.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.assertInitialised();
|
||||
const operations = docs.flatMap((doc) => [{index: {_index: this.indexDefinition.indexName, _id: doc.id}}, doc]);
|
||||
await this.client.bulk({operations, refresh: false});
|
||||
await this.indexDocuments(docs);
|
||||
}
|
||||
|
||||
async refreshIndex(): Promise<void> {
|
||||
@@ -228,25 +233,28 @@ export class ElasticsearchIndexAdapter<
|
||||
}
|
||||
this.assertInitialised();
|
||||
const operations = ids.map((id) => ({delete: {_index: this.indexDefinition.indexName, _id: id}}));
|
||||
await this.client.bulk({operations, refresh: false});
|
||||
const response = await this.client.bulk({operations, refresh: false});
|
||||
this.assertBulkSucceeded(response, 'delete');
|
||||
}
|
||||
|
||||
async deleteByQuery(query: Record<string, unknown>): Promise<void> {
|
||||
this.assertInitialised();
|
||||
await this.client.deleteByQuery({
|
||||
const response = await this.client.deleteByQuery({
|
||||
index: this.indexDefinition.indexName,
|
||||
query,
|
||||
refresh: false,
|
||||
});
|
||||
this.assertDeleteByQuerySucceeded(response);
|
||||
}
|
||||
|
||||
async deleteAllDocuments(): Promise<void> {
|
||||
this.assertInitialised();
|
||||
await this.client.deleteByQuery({
|
||||
const response = await this.client.deleteByQuery({
|
||||
index: this.indexDefinition.indexName,
|
||||
query: {match_all: {}},
|
||||
refresh: true,
|
||||
});
|
||||
this.assertDeleteByQuerySucceeded(response);
|
||||
}
|
||||
|
||||
async search(query: string, filters: TFilters, options?: SearchOptions): Promise<SearchResult<TResult>> {
|
||||
@@ -307,6 +315,29 @@ export class ElasticsearchIndexAdapter<
|
||||
}
|
||||
}
|
||||
|
||||
private assertBulkSucceeded(response: BulkResponse, operation: BulkOperationType): void {
|
||||
if (!response.errors) return;
|
||||
const context = `Elasticsearch bulk ${operation} failed for index ${this.indexDefinition.indexName}`;
|
||||
for (const item of response.items ?? []) {
|
||||
const failure = item[operation];
|
||||
if (failure?.error) {
|
||||
throw new Error(`${context}: ${summarizeOperationFailure(failure.status, failure.error)}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`${context}: response reported errors without ${operation} item error details`);
|
||||
}
|
||||
|
||||
private assertDeleteByQuerySucceeded(response: DeleteByQueryResponse): void {
|
||||
const context = `Elasticsearch delete by query failed for index ${this.indexDefinition.indexName}`;
|
||||
if (response.timed_out) {
|
||||
throw new Error(`${context}: timed out`);
|
||||
}
|
||||
const failure = response.failures?.[0];
|
||||
if (failure) {
|
||||
throw new Error(`${context}: ${summarizeOperationFailure(failure.status, failure.cause)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeDeepPaginationSearch(
|
||||
searchParams: ElasticsearchBaseSearchRequest,
|
||||
offset: number,
|
||||
@@ -403,6 +434,12 @@ export class ElasticsearchIndexAdapter<
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeOperationFailure(status: number, error: ErrorCause): string {
|
||||
const errorType =
|
||||
typeof error.type === 'string' ? JSON.stringify(error.type.slice(0, ERROR_TYPE_MAX_LENGTH)) : 'missing error type';
|
||||
return `HTTP ${status}, ${errorType}`;
|
||||
}
|
||||
|
||||
function isResourceAlreadyExistsError(error: unknown): boolean {
|
||||
if (error == null || typeof error !== 'object') {
|
||||
return false;
|
||||
|
||||
@@ -291,10 +291,15 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
|
||||
.catch(() => {});
|
||||
return finalize(params);
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
|
||||
}, FETCH_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
let payload: unknown;
|
||||
try {
|
||||
const res = await fetch(fetchUrl, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
signal: controller.signal,
|
||||
headers: {Accept: 'application/json'},
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -312,6 +317,9 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
|
||||
outcome: 'request_failed',
|
||||
httpStatus: null,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
}
|
||||
const parsedResponse = RawIpInfoResponseSchema.safeParse(payload);
|
||||
if (!parsedResponse.success) {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"undici-types": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {addAbortListener} from 'node:events';
|
||||
import {finished} from 'node:stream';
|
||||
import {HttpStatus, REDIRECT_STATUS_CODES} from '@fluxer/constants/src/HttpConstants';
|
||||
import {
|
||||
buildRequestHeaders,
|
||||
classifyRequestError,
|
||||
createRequestSignal,
|
||||
DEFAULT_MAX_REDIRECTS,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
normalizeMaxRedirects,
|
||||
normalizeTimeoutMs,
|
||||
resolveRequestBody,
|
||||
statusToMetricLabel,
|
||||
} from '@pkgs/http_client/src/HttpClientRequestInternals';
|
||||
@@ -20,32 +26,28 @@ import type {
|
||||
StreamResponse,
|
||||
} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import {HttpError} from '@pkgs/http_client/src/HttpError';
|
||||
import type {Dispatcher} from 'undici-types';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_MAX_REDIRECTS = 5;
|
||||
const DEFAULT_SERVICE_NAME = 'unknown';
|
||||
|
||||
interface ResolvedClientConfig {
|
||||
defaultHeaders: Record<string, string>;
|
||||
defaultHeaders: Headers;
|
||||
defaultTimeoutMs: number;
|
||||
maxRedirects: number;
|
||||
requestUrlPolicy?: RequestUrlPolicy;
|
||||
telemetry?: HttpClientTelemetry;
|
||||
}
|
||||
|
||||
function createDefaultHeaders(userAgent: string, defaultHeaders?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: '*/*',
|
||||
'User-Agent': userAgent,
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
};
|
||||
if (defaultHeaders) {
|
||||
for (const [key, value] of Object.entries(defaultHeaders)) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
function createDefaultHeaders(userAgent: string, defaultHeaders?: Record<string, string>): Headers {
|
||||
return buildRequestHeaders(
|
||||
{
|
||||
Accept: '*/*',
|
||||
'User-Agent': userAgent,
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
defaultHeaders,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveClientConfig(
|
||||
@@ -61,14 +63,8 @@ function resolveClientConfig(
|
||||
telemetry,
|
||||
};
|
||||
}
|
||||
const maxRedirects =
|
||||
typeof userAgentOrOptions.maxRedirects === 'number' && userAgentOrOptions.maxRedirects >= 0
|
||||
? userAgentOrOptions.maxRedirects
|
||||
: DEFAULT_MAX_REDIRECTS;
|
||||
const defaultTimeoutMs =
|
||||
typeof userAgentOrOptions.defaultTimeoutMs === 'number' && userAgentOrOptions.defaultTimeoutMs > 0
|
||||
? userAgentOrOptions.defaultTimeoutMs
|
||||
: DEFAULT_TIMEOUT_MS;
|
||||
const maxRedirects = normalizeMaxRedirects(userAgentOrOptions.maxRedirects);
|
||||
const defaultTimeoutMs = normalizeTimeoutMs(userAgentOrOptions.defaultTimeoutMs, 'defaultTimeoutMs');
|
||||
return {
|
||||
defaultHeaders: createDefaultHeaders(userAgentOrOptions.userAgent, userAgentOrOptions.defaultHeaders),
|
||||
defaultTimeoutMs,
|
||||
@@ -80,10 +76,10 @@ function resolveClientConfig(
|
||||
|
||||
function createFetchInit(
|
||||
method: HttpMethod,
|
||||
headers: Record<string, string>,
|
||||
headers: Headers,
|
||||
body: string | undefined,
|
||||
signal: AbortSignal,
|
||||
dispatcher: NonNullable<RequestInit['dispatcher']> | undefined,
|
||||
dispatcher: Dispatcher | undefined,
|
||||
): RequestInit {
|
||||
return {
|
||||
method,
|
||||
@@ -95,10 +91,15 @@ function createFetchInit(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRequestUrlPolicyDispatcher(
|
||||
requestUrlPolicy: RequestUrlPolicy | undefined,
|
||||
): NonNullable<RequestInit['dispatcher']> | undefined {
|
||||
return (requestUrlPolicy as {dispatcher?: NonNullable<RequestInit['dispatcher']>} | undefined)?.dispatcher;
|
||||
function parseRequestUrl(value: string, base?: string): URL {
|
||||
const url = URL.parse(value, base);
|
||||
if (!url) {
|
||||
throw new TypeError('Invalid URL');
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new TypeError('Request URL must not include credentials');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function isRedirectStatus(status: number): boolean {
|
||||
@@ -106,33 +107,36 @@ function isRedirectStatus(status: number): boolean {
|
||||
}
|
||||
|
||||
const SENSITIVE_REDIRECT_HEADERS = new Set(['authorization', 'cookie', 'proxy-authorization']);
|
||||
const BODY_RELATED_HEADERS = new Set(['content-type', 'content-length', 'transfer-encoding']);
|
||||
const BODY_RELATED_HEADERS = new Set([
|
||||
'content-encoding',
|
||||
'content-language',
|
||||
'content-length',
|
||||
'content-location',
|
||||
'content-type',
|
||||
'transfer-encoding',
|
||||
]);
|
||||
|
||||
function shouldSwitchToGet(status: number, method: HttpMethod): boolean {
|
||||
if (status === HttpStatus.SEE_OTHER) {
|
||||
return true;
|
||||
return method !== 'GET' && method !== 'HEAD';
|
||||
}
|
||||
if (status === HttpStatus.MOVED_PERMANENTLY || status === HttpStatus.FOUND) {
|
||||
return method !== 'GET' && method !== 'HEAD';
|
||||
return method === 'POST';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildRedirectHeaders(
|
||||
headers: Record<string, string>,
|
||||
stripSensitive: boolean,
|
||||
dropBodyHeaders: boolean,
|
||||
): Record<string, string> {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (stripSensitive && SENSITIVE_REDIRECT_HEADERS.has(lowerKey)) {
|
||||
continue;
|
||||
function buildRedirectHeaders(headers: Headers, stripSensitive: boolean, dropBodyHeaders: boolean): Headers {
|
||||
const nextHeaders = new Headers(headers);
|
||||
if (stripSensitive) {
|
||||
for (const name of SENSITIVE_REDIRECT_HEADERS) {
|
||||
nextHeaders.delete(name);
|
||||
}
|
||||
if (dropBodyHeaders && BODY_RELATED_HEADERS.has(lowerKey)) {
|
||||
continue;
|
||||
}
|
||||
if (dropBodyHeaders) {
|
||||
for (const name of BODY_RELATED_HEADERS) {
|
||||
nextHeaders.delete(name);
|
||||
}
|
||||
nextHeaders[key] = value;
|
||||
}
|
||||
return nextHeaders;
|
||||
}
|
||||
@@ -140,27 +144,33 @@ function buildRedirectHeaders(
|
||||
async function fetchWithRedirects(
|
||||
url: string,
|
||||
method: HttpMethod,
|
||||
headers: Record<string, string>,
|
||||
headers: Headers,
|
||||
body: string | undefined,
|
||||
signal: AbortSignal,
|
||||
maxRedirects: number,
|
||||
requestUrlPolicy?: RequestUrlPolicy,
|
||||
): Promise<Response> {
|
||||
let currentUrl = new URL(url);
|
||||
let currentUrl = parseRequestUrl(url);
|
||||
let currentMethod: HttpMethod = method;
|
||||
let currentBody = body;
|
||||
let currentHeaders = {...headers};
|
||||
const dispatcher = resolveRequestUrlPolicyDispatcher(requestUrlPolicy);
|
||||
await validateRequestUrlPolicy(requestUrlPolicy, currentUrl, {
|
||||
phase: 'initial',
|
||||
redirectCount: 0,
|
||||
});
|
||||
let currentHeaders = headers;
|
||||
const dispatcher = requestUrlPolicy?.dispatcher;
|
||||
await validateRequestUrlPolicy(
|
||||
requestUrlPolicy,
|
||||
currentUrl,
|
||||
{
|
||||
phase: 'initial',
|
||||
redirectCount: 0,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
let response = await fetch(
|
||||
currentUrl.href,
|
||||
createFetchInit(currentMethod, currentHeaders, currentBody, signal, dispatcher),
|
||||
);
|
||||
let redirectCount = 0;
|
||||
while (isRedirectStatus(response.status)) {
|
||||
await response.body?.cancel();
|
||||
if (redirectCount >= maxRedirects) {
|
||||
throw new HttpError(`Maximum number of redirects (${maxRedirects}) exceeded`);
|
||||
}
|
||||
@@ -169,7 +179,7 @@ async function fetchWithRedirects(
|
||||
throw new HttpError('Received redirect response without Location header', response.status);
|
||||
}
|
||||
const previousUrl = currentUrl;
|
||||
const nextUrl = new URL(location, response.url || currentUrl.href);
|
||||
const nextUrl = parseRequestUrl(location, response.url || currentUrl.href);
|
||||
const switchToGet = shouldSwitchToGet(response.status, currentMethod);
|
||||
if (switchToGet) {
|
||||
currentMethod = 'GET';
|
||||
@@ -180,11 +190,16 @@ async function fetchWithRedirects(
|
||||
const stripSensitive = previousOrigin !== nextOrigin;
|
||||
currentHeaders = buildRedirectHeaders(currentHeaders, stripSensitive, switchToGet);
|
||||
const nextRedirectCount = redirectCount + 1;
|
||||
await validateRequestUrlPolicy(requestUrlPolicy, nextUrl, {
|
||||
phase: 'redirect',
|
||||
redirectCount: nextRedirectCount,
|
||||
previousUrl: previousUrl.href,
|
||||
});
|
||||
await validateRequestUrlPolicy(
|
||||
requestUrlPolicy,
|
||||
nextUrl,
|
||||
{
|
||||
phase: 'redirect',
|
||||
redirectCount: nextRedirectCount,
|
||||
previousUrl: previousUrl.href,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
currentUrl = nextUrl;
|
||||
response = await fetch(
|
||||
currentUrl.href,
|
||||
@@ -199,11 +214,21 @@ async function validateRequestUrlPolicy(
|
||||
requestUrlPolicy: RequestUrlPolicy | undefined,
|
||||
url: URL,
|
||||
context: RequestUrlValidationContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal.throwIfAborted();
|
||||
if (!requestUrlPolicy) {
|
||||
return;
|
||||
}
|
||||
await requestUrlPolicy.validate(url, context);
|
||||
let abortSubscription: Disposable | undefined;
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
abortSubscription = addAbortListener(signal, () => reject(signal.reason));
|
||||
void requestUrlPolicy.validate(url, context).then(resolve, reject);
|
||||
});
|
||||
} finally {
|
||||
abortSubscription?.[Symbol.dispose]();
|
||||
}
|
||||
}
|
||||
|
||||
function recordSuccessfulRequestMetrics(
|
||||
@@ -287,10 +312,11 @@ export function createHttpClient(
|
||||
const startTime = Date.now();
|
||||
const method: HttpMethod = opts.method ?? 'GET';
|
||||
const serviceName = opts.serviceName ?? DEFAULT_SERVICE_NAME;
|
||||
const timeoutMs = typeof opts.timeout === 'number' && opts.timeout > 0 ? opts.timeout : config.defaultTimeoutMs;
|
||||
const requestSignal = createRequestSignal(timeoutMs, opts.signal);
|
||||
const timeoutMs = normalizeTimeoutMs(opts.timeout, 'timeout', config.defaultTimeoutMs);
|
||||
const headers = buildRequestHeaders(config.defaultHeaders, opts.headers);
|
||||
const body = resolveRequestBody(opts.body, headers);
|
||||
const requestSignal = createRequestSignal(timeoutMs, opts.signal);
|
||||
let responseOwnsSignal = false;
|
||||
try {
|
||||
const response = await fetchWithRedirects(
|
||||
opts.url,
|
||||
@@ -309,8 +335,16 @@ export function createHttpClient(
|
||||
};
|
||||
const durationMs = Date.now() - startTime;
|
||||
recordSuccessfulRequestMetrics(metrics, serviceName, method, result.status, durationMs);
|
||||
if (result.stream) {
|
||||
const stopObserving = finished(result.stream, () => {
|
||||
stopObserving();
|
||||
requestSignal.cleanup();
|
||||
});
|
||||
responseOwnsSignal = true;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
requestSignal.abort(error);
|
||||
const durationMs = Date.now() - startTime;
|
||||
if (error instanceof HttpError) {
|
||||
recordHttpErrorMetrics(metrics, serviceName, method, error.status?.toString() ?? 'error', durationMs);
|
||||
@@ -327,12 +361,11 @@ export function createHttpClient(
|
||||
classifiedError.errorType,
|
||||
);
|
||||
} finally {
|
||||
requestSignal.cleanup();
|
||||
if (!responseOwnsSignal) {
|
||||
requestSignal.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
async function sendRequest(opts: RequestOptions): Promise<StreamResponse> {
|
||||
return request(opts);
|
||||
}
|
||||
async function streamToString(stream: ResponseStream): Promise<string> {
|
||||
if (!stream) {
|
||||
return '';
|
||||
@@ -341,7 +374,7 @@ export function createHttpClient(
|
||||
}
|
||||
return {
|
||||
request,
|
||||
sendRequest,
|
||||
sendRequest: request,
|
||||
streamToString,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function formatUrlForDiagnostics(value: string | URL): string {
|
||||
const url = typeof value === 'string' ? URL.parse(value) : value;
|
||||
if (!url) {
|
||||
return '[invalid URL]';
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return '[non-HTTP URL]';
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {addAbortListener} from 'node:events';
|
||||
import type {HttpErrorType} from '@pkgs/http_client/src/HttpError';
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 30000;
|
||||
export const DEFAULT_MAX_REDIRECTS = 5;
|
||||
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
||||
|
||||
const NETWORK_ERROR_CODES = new Set([
|
||||
'ENOTFOUND',
|
||||
'ECONNREFUSED',
|
||||
@@ -11,26 +16,13 @@ const NETWORK_ERROR_CODES = new Set([
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
]);
|
||||
const NETWORK_ERROR_MESSAGE_FRAGMENTS = [
|
||||
'ENOTFOUND',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EAI_AGAIN',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'fetch failed',
|
||||
] as const;
|
||||
const NETWORK_ERROR_MESSAGE_FRAGMENTS = [...NETWORK_ERROR_CODES, 'fetch failed'];
|
||||
|
||||
interface NodeErrorLike {
|
||||
code?: unknown;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
interface NodeCauseLike {
|
||||
code?: unknown;
|
||||
}
|
||||
|
||||
interface ClassifiedRequestError {
|
||||
message: string;
|
||||
isNetworkError: boolean;
|
||||
@@ -39,9 +31,30 @@ interface ClassifiedRequestError {
|
||||
|
||||
interface RequestSignalContext {
|
||||
signal: AbortSignal;
|
||||
abort(reason: unknown): void;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
export function normalizeMaxRedirects(value: number | undefined): number {
|
||||
const maxRedirects = value === undefined ? DEFAULT_MAX_REDIRECTS : value;
|
||||
if (!Number.isSafeInteger(maxRedirects) || maxRedirects < 0) {
|
||||
throw new RangeError('maxRedirects must be a nonnegative safe integer');
|
||||
}
|
||||
return maxRedirects;
|
||||
}
|
||||
|
||||
export function normalizeTimeoutMs(
|
||||
value: number | undefined,
|
||||
setting: 'defaultTimeoutMs' | 'timeout',
|
||||
defaultTimeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): number {
|
||||
const timeoutMs = value === undefined ? defaultTimeoutMs : value;
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) {
|
||||
throw new RangeError(`${setting} must be an integer between 1 and 2147483647`);
|
||||
}
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
function isNodeErrorLike(error: unknown): error is NodeErrorLike {
|
||||
return typeof error === 'object' && error !== null;
|
||||
}
|
||||
@@ -53,84 +66,61 @@ function resolveErrorCode(error: unknown): string | undefined {
|
||||
if (typeof error.code === 'string') {
|
||||
return error.code;
|
||||
}
|
||||
if (!isNodeErrorLike(error.cause)) {
|
||||
return undefined;
|
||||
}
|
||||
const cause: NodeCauseLike = error.cause;
|
||||
if (typeof cause.code === 'string') {
|
||||
return cause.code;
|
||||
if (isNodeErrorLike(error.cause) && typeof error.cause.code === 'string') {
|
||||
return error.cause.code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildRequestHeaders(
|
||||
defaultHeaders: Record<string, string>,
|
||||
defaultHeaders: Headers | Record<string, string>,
|
||||
requestHeaders?: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
if (!requestHeaders) {
|
||||
return {...defaultHeaders};
|
||||
): Headers {
|
||||
const headers = new Headers(defaultHeaders);
|
||||
for (const [name, value] of Object.entries(requestHeaders ?? {})) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
return {...defaultHeaders, ...requestHeaders};
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function resolveRequestBody(body: unknown, headers: Record<string, string>): string | undefined {
|
||||
export function resolveRequestBody(body: unknown, headers: Headers): string | undefined {
|
||||
if (body === null || body === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (body instanceof URLSearchParams) {
|
||||
if (!headers['Content-Type'] && !headers['content-type']) {
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
||||
if (!headers.get('content-type')) {
|
||||
headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
|
||||
}
|
||||
return body.toString();
|
||||
}
|
||||
if (typeof body === 'string') {
|
||||
return body;
|
||||
}
|
||||
if (!headers['Content-Type'] && !headers['content-type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
if (!headers.get('content-type')) {
|
||||
headers.set('content-type', 'application/json');
|
||||
}
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
export function createRequestSignal(timeoutMs: number, inputSignal?: AbortSignal): RequestSignalContext {
|
||||
const timeoutController = new AbortController();
|
||||
const combinedController = new AbortController();
|
||||
const attachedListeners: Array<{
|
||||
signal: AbortSignal;
|
||||
listener: () => void;
|
||||
}> = [];
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
timeoutController.abort('Request timed out');
|
||||
controller.abort('Request timed out');
|
||||
}, timeoutMs);
|
||||
function abortWithReason(reason: unknown): void {
|
||||
if (!combinedController.signal.aborted) {
|
||||
combinedController.abort(reason);
|
||||
}
|
||||
}
|
||||
function attachSignal(signal: AbortSignal): void {
|
||||
if (signal.aborted) {
|
||||
abortWithReason(signal.reason);
|
||||
return;
|
||||
}
|
||||
const listener = () => {
|
||||
abortWithReason(signal.reason);
|
||||
};
|
||||
signal.addEventListener('abort', listener, {once: true});
|
||||
attachedListeners.push({signal, listener});
|
||||
}
|
||||
if (inputSignal) {
|
||||
attachSignal(inputSignal);
|
||||
}
|
||||
attachSignal(timeoutController.signal);
|
||||
function cleanup(): void {
|
||||
clearTimeout(timeoutId);
|
||||
for (const attachedListener of attachedListeners) {
|
||||
attachedListener.signal.removeEventListener('abort', attachedListener.listener);
|
||||
}
|
||||
timeoutId.unref();
|
||||
let inputSubscription: Disposable | undefined;
|
||||
if (inputSignal?.aborted) {
|
||||
controller.abort(inputSignal.reason);
|
||||
} else if (inputSignal) {
|
||||
inputSubscription = addAbortListener(inputSignal, () => controller.abort(inputSignal.reason));
|
||||
}
|
||||
return {
|
||||
signal: combinedController.signal,
|
||||
cleanup,
|
||||
signal: controller.signal,
|
||||
abort: (reason) => controller.abort(reason),
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
inputSubscription?.[Symbol.dispose]();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {HttpClientTelemetry} from '@pkgs/http_client/src/HttpClientTelemetryTypes';
|
||||
import type {Dispatcher} from 'undici-types';
|
||||
|
||||
export type ResponseStream = ReadableStream<Uint8Array> | null;
|
||||
export type HttpMethod = 'GET' | 'POST' | 'HEAD' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
|
||||
@@ -13,6 +14,7 @@ export interface RequestUrlValidationContext {
|
||||
}
|
||||
|
||||
export interface RequestUrlPolicy {
|
||||
readonly dispatcher?: Dispatcher;
|
||||
validate(url: URL, context: RequestUrlValidationContext): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import dns from 'node:dns';
|
||||
import type {LookupFunction} from 'node:net';
|
||||
import {BlockList, isIP} from 'node:net';
|
||||
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
|
||||
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import {HttpError} from '@pkgs/http_client/src/HttpError';
|
||||
import {Agent} from 'undici';
|
||||
import type {Dispatcher} from 'undici-types';
|
||||
|
||||
const DEFAULT_DNS_CACHE_TTL_MS = 60000;
|
||||
const DNS_CACHE_MAX_ENTRIES = 10000;
|
||||
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
const HOSTNAME_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
@@ -203,14 +207,14 @@ export function isPubliclyRoutableUrlShape(url: URL): boolean {
|
||||
|
||||
function getPolicyErrorContext(context: RequestUrlValidationContext): string {
|
||||
if (context.phase === 'redirect') {
|
||||
const previous = context.previousUrl ?? 'unknown';
|
||||
const previous = context.previousUrl === undefined ? 'unknown' : formatUrlForDiagnostics(context.previousUrl);
|
||||
return `redirect #${context.redirectCount} from ${previous}`;
|
||||
}
|
||||
return 'initial request';
|
||||
}
|
||||
|
||||
function createBlockedRequestError(url: URL, context: RequestUrlValidationContext, reason: string): HttpError {
|
||||
const message = `Blocked outbound ${getPolicyErrorContext(context)} to ${url.href}: ${reason}`;
|
||||
const message = `Blocked outbound ${getPolicyErrorContext(context)} to ${formatUrlForDiagnostics(url)}: ${reason}`;
|
||||
return new HttpError(message, undefined, undefined, true, 'network_error');
|
||||
}
|
||||
|
||||
@@ -219,20 +223,7 @@ async function defaultLookupHost(hostname: string): Promise<Array<string>> {
|
||||
return addresses.map((addressEntry) => addressEntry.address);
|
||||
}
|
||||
|
||||
function deduplicateAddresses(addresses: Array<string>): Array<string> {
|
||||
const seen = new Set<string>();
|
||||
const deduplicated: Array<string> = [];
|
||||
for (const address of addresses) {
|
||||
if (seen.has(address)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(address);
|
||||
deduplicated.push(address);
|
||||
}
|
||||
return deduplicated;
|
||||
}
|
||||
|
||||
function createBlocklistDispatcher(allowPrivateAddresses: boolean): NonNullable<RequestInit['dispatcher']> {
|
||||
function createBlocklistDispatcher(allowPrivateAddresses: boolean): Dispatcher {
|
||||
const lookup: LookupFunction = (hostname, options, callback) => {
|
||||
dns.lookup(hostname, {...options, all: true, order: options.order ?? 'verbatim'}, (error, addresses) => {
|
||||
if (error) {
|
||||
@@ -259,33 +250,46 @@ function createBlocklistDispatcher(allowPrivateAddresses: boolean): NonNullable<
|
||||
connect: {
|
||||
lookup,
|
||||
},
|
||||
}) as unknown as NonNullable<RequestInit['dispatcher']>;
|
||||
}) as unknown as Dispatcher;
|
||||
}
|
||||
|
||||
interface PublicInternetRequestUrlPolicy extends RequestUrlPolicy {
|
||||
dispatcher: NonNullable<RequestInit['dispatcher']>;
|
||||
readonly dispatcher: Dispatcher;
|
||||
}
|
||||
|
||||
export function createPublicInternetRequestUrlPolicy(
|
||||
options?: PublicInternetRequestUrlPolicyOptions,
|
||||
): PublicInternetRequestUrlPolicy {
|
||||
const dnsCacheTtlMs =
|
||||
typeof options?.dnsCacheTtlMs === 'number' && options.dnsCacheTtlMs > 0
|
||||
? options.dnsCacheTtlMs
|
||||
: DEFAULT_DNS_CACHE_TTL_MS;
|
||||
const dnsCacheTtlMs = options?.dnsCacheTtlMs ?? DEFAULT_DNS_CACHE_TTL_MS;
|
||||
if (!Number.isFinite(dnsCacheTtlMs) || dnsCacheTtlMs <= 0) {
|
||||
throw new RangeError('DNS cache TTL must be a positive finite number');
|
||||
}
|
||||
const lookupHost = options?.lookupHost ?? defaultLookupHost;
|
||||
const allowPrivateAddresses = options?.allowPrivateAddresses === true;
|
||||
const dnsCache = new Map<string, CachedLookupResult>();
|
||||
async function resolveHostname(hostname: string): Promise<Array<string>> {
|
||||
const now = Date.now();
|
||||
const now = performance.now();
|
||||
const cached = dnsCache.get(hostname);
|
||||
dnsCache.delete(hostname);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
dnsCache.set(hostname, cached);
|
||||
return cached.addresses;
|
||||
}
|
||||
const resolvedAddresses = deduplicateAddresses(await lookupHost(hostname));
|
||||
const expiresAt = now + dnsCacheTtlMs;
|
||||
const resolvedAddresses = [...new Set(await lookupHost(hostname))];
|
||||
const current = dnsCache.get(hostname);
|
||||
if (expiresAt <= performance.now() || (current && current.expiresAt > expiresAt)) {
|
||||
return resolvedAddresses;
|
||||
}
|
||||
dnsCache.delete(hostname);
|
||||
if (dnsCache.size >= DNS_CACHE_MAX_ENTRIES) {
|
||||
const oldest = dnsCache.keys().next();
|
||||
assert(!oldest.done, 'A full DNS cache must contain an eviction candidate');
|
||||
dnsCache.delete(oldest.value);
|
||||
}
|
||||
dnsCache.set(hostname, {
|
||||
addresses: resolvedAddresses,
|
||||
expiresAt: now + dnsCacheTtlMs,
|
||||
expiresAt,
|
||||
});
|
||||
return resolvedAddresses;
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('HttpClient', () => {
|
||||
expect(response.url).toBe(`${redirectServer.url}/target`);
|
||||
expect(json.path).toBe('/target');
|
||||
});
|
||||
it('follows 301 redirect and changes method to GET for non-GET requests', async () => {
|
||||
it('follows 301 redirect and changes POST to GET', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(301, {Location: `${redirectServer.url}/moved`});
|
||||
@@ -260,7 +260,7 @@ describe('HttpClient', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toBe('/found');
|
||||
});
|
||||
it('follows 302 redirect and changes method to GET for non-GET requests', async () => {
|
||||
it('follows 302 redirect preserving PATCH', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(302, {Location: `${redirectServer.url}/found`});
|
||||
@@ -278,7 +278,7 @@ describe('HttpClient', () => {
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.method).toBe('GET');
|
||||
expect(json.method).toBe('PATCH');
|
||||
});
|
||||
it('follows 303 redirect and changes method to GET', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@pkgs/initialization",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/Init.ts",
|
||||
"./src/*": "./src/*.ts",
|
||||
"./*": "./*"
|
||||
},
|
||||
"main": "./src/Init.ts",
|
||||
"types": "./src/Init.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {startServiceInitialization} from '@pkgs/initialization/src/Init';
|
||||
import type {ShutdownFn} from '@pkgs/initialization/src/ServiceInitializationTypes';
|
||||
|
||||
interface CreateServiceInstrumentationOptions {
|
||||
serviceName: string;
|
||||
config: {
|
||||
env: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function createServiceInstrumentation(options: CreateServiceInstrumentationOptions): ShutdownFn {
|
||||
const {serviceName, config} = options;
|
||||
return startServiceInitialization({
|
||||
serviceName,
|
||||
environment: config.env,
|
||||
});
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ServiceInitConfig, ShutdownFn} from '@pkgs/initialization/src/ServiceInitializationTypes';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export async function initializeService(_config: ServiceInitConfig): Promise<void> {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
export async function shutdownService(): Promise<void> {
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
export function isServiceInitialized(): boolean {
|
||||
return initialized;
|
||||
}
|
||||
|
||||
export function startServiceInitialization(config: ServiceInitConfig): ShutdownFn {
|
||||
initializeService(config).catch((err: unknown) => {
|
||||
process.stderr.write(`[instrument] Failed to initialize instrumentation: ${err}\n`);
|
||||
});
|
||||
return shutdownService;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface ServiceInitConfig {
|
||||
serviceName: string;
|
||||
serviceVersion?: string;
|
||||
environment?: string;
|
||||
}
|
||||
|
||||
export type ShutdownFn = () => Promise<void>;
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfigs/package.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
|
||||
"@pkgs/*": ["../*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -28,6 +28,11 @@ export interface IKVSubscription {
|
||||
removeAllListeners(event?: 'message' | 'error'): void;
|
||||
}
|
||||
|
||||
export interface KVPurgeBatchResult {
|
||||
urls: Array<string>;
|
||||
tokensConsumed: number;
|
||||
}
|
||||
|
||||
export interface KVRateLimitResult {
|
||||
allowed: boolean;
|
||||
limit: number;
|
||||
@@ -98,10 +103,7 @@ export interface IKVProvider {
|
||||
maxTokens: number,
|
||||
refillRate: number,
|
||||
refillIntervalMs: number,
|
||||
): Promise<{
|
||||
urls: Array<string>;
|
||||
tokensConsumed: number;
|
||||
}>;
|
||||
): Promise<KVPurgeBatchResult>;
|
||||
pipeline(): IKVPipeline;
|
||||
multi(): IKVPipeline;
|
||||
isClustered(): boolean;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import type {IKVPipeline, IKVProvider, IKVSubscription, KVRateLimitResult} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import type {
|
||||
IKVPipeline,
|
||||
IKVProvider,
|
||||
IKVSubscription,
|
||||
KVPurgeBatchResult,
|
||||
KVRateLimitResult,
|
||||
} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {
|
||||
type IKVLogger,
|
||||
type KVClientConfig,
|
||||
type ResolvedKVClientConfig,
|
||||
resolveKVClientConfig,
|
||||
} from '@pkgs/kv_client/src/KVClientConfig';
|
||||
import {KVClientError, KVClientErrorCode} from '@pkgs/kv_client/src/KVClientError';
|
||||
import {createInvalidResponseError, KVClientError, KVClientErrorCode} from '@pkgs/kv_client/src/KVClientError';
|
||||
import {resolveKVClusterConnection} from '@pkgs/kv_client/src/KVClusterConnection';
|
||||
import {
|
||||
createStringEntriesFromPairs,
|
||||
createZSetMembersFromScorePairs,
|
||||
@@ -21,6 +28,30 @@ import {KVPipeline} from '@pkgs/kv_client/src/KVPipeline';
|
||||
import {KVSubscription} from '@pkgs/kv_client/src/KVSubscription';
|
||||
import Redis, {Cluster} from 'ioredis';
|
||||
|
||||
const MAX_DATE_TIMESTAMP = 8640000000000000;
|
||||
|
||||
const DECODE_BUCKET_STATE_SCRIPT = `
|
||||
local function decodeBucketState(rawState, amountField, timestampField)
|
||||
local ok, state = pcall(cjson.decode, rawState)
|
||||
if not ok or type(state) ~= 'table' then
|
||||
error('invalid stored bucket state')
|
||||
end
|
||||
local amount = state[amountField]
|
||||
if type(amount) ~= 'number' or amount ~= amount or amount < 0 or amount == math.huge then
|
||||
error('invalid stored bucket amount: ' .. amountField)
|
||||
end
|
||||
local timestamp = state[timestampField]
|
||||
if type(timestamp) ~= 'number'
|
||||
or timestamp ~= timestamp
|
||||
or math.abs(timestamp) > ${MAX_DATE_TIMESTAMP}
|
||||
or timestamp ~= math.floor(timestamp)
|
||||
then
|
||||
error('invalid stored bucket timestamp: ' .. timestampField)
|
||||
end
|
||||
return amount, timestamp
|
||||
end
|
||||
`;
|
||||
|
||||
const RELEASE_LOCK_SCRIPT = `
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('DEL', KEYS[1])
|
||||
@@ -42,6 +73,7 @@ end
|
||||
return 0
|
||||
`;
|
||||
const TRY_CONSUME_TOKENS_SCRIPT = `
|
||||
${DECODE_BUCKET_STATE_SCRIPT}
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local requested = tonumber(ARGV[2])
|
||||
@@ -54,11 +86,7 @@ local tokens = maxTokens
|
||||
local lastRefill = now
|
||||
|
||||
if data then
|
||||
local ok, bucket = pcall(cjson.decode, data)
|
||||
if ok and bucket then
|
||||
tokens = tonumber(bucket.tokens) or maxTokens
|
||||
lastRefill = tonumber(bucket.lastRefill) or now
|
||||
end
|
||||
tokens, lastRefill = decodeBucketState(data, 'tokens', 'lastRefill')
|
||||
end
|
||||
|
||||
local elapsed = now - lastRefill
|
||||
@@ -84,6 +112,7 @@ redis.call('SET', key, cjson.encode({tokens = tokens, lastRefill = lastRefill}),
|
||||
return consumed
|
||||
`;
|
||||
const CHECK_LEAKY_BUCKET_LIMIT_SCRIPT = `
|
||||
${DECODE_BUCKET_STATE_SCRIPT}
|
||||
local key = KEYS[1]
|
||||
local nowMs = tonumber(ARGV[1])
|
||||
local limit = tonumber(ARGV[2])
|
||||
@@ -107,11 +136,7 @@ local updatedAt = nowMs
|
||||
|
||||
local rawState = redis.call('GET', key)
|
||||
if rawState then
|
||||
local ok, state = pcall(cjson.decode, rawState)
|
||||
if ok and state then
|
||||
level = tonumber(state.level) or 0
|
||||
updatedAt = tonumber(state.updatedAt) or nowMs
|
||||
end
|
||||
level, updatedAt = decodeBucketState(rawState, 'level', 'updatedAt')
|
||||
end
|
||||
|
||||
local elapsed = nowMs - updatedAt
|
||||
@@ -181,6 +206,7 @@ redis.call('SET', KEYS[2], ARGV[2])
|
||||
return 1
|
||||
`;
|
||||
const DEQUEUE_PURGE_BATCH_SCRIPT = `
|
||||
${DECODE_BUCKET_STATE_SCRIPT}
|
||||
local queueKey = KEYS[1]
|
||||
local bucketKey = KEYS[2]
|
||||
local maxItems = tonumber(ARGV[1])
|
||||
@@ -191,18 +217,14 @@ local refillIntervalMs = tonumber(ARGV[5])
|
||||
|
||||
local queueSize = redis.call('SCARD', queueKey)
|
||||
if queueSize == 0 then
|
||||
return cjson.encode({urls = {}, tokens = 0})
|
||||
return '{"urls":[],"tokens":0}'
|
||||
end
|
||||
|
||||
local tokens = maxTokens
|
||||
local lastRefill = now
|
||||
local data = redis.call('GET', bucketKey)
|
||||
if data then
|
||||
local ok, bucket = pcall(cjson.decode, data)
|
||||
if ok and bucket then
|
||||
tokens = tonumber(bucket.tokens) or maxTokens
|
||||
lastRefill = tonumber(bucket.lastRefill) or now
|
||||
end
|
||||
tokens, lastRefill = decodeBucketState(data, 'tokens', 'lastRefill')
|
||||
end
|
||||
|
||||
local elapsed = now - lastRefill
|
||||
@@ -215,7 +237,7 @@ end
|
||||
local toPop = math.min(maxItems, math.floor(tokens), queueSize)
|
||||
if toPop <= 0 then
|
||||
redis.call('SET', bucketKey, cjson.encode({tokens = tokens, lastRefill = lastRefill}), 'EX', 3600)
|
||||
return cjson.encode({urls = {}, tokens = 0})
|
||||
return '{"urls":[],"tokens":0}'
|
||||
end
|
||||
|
||||
local urls = redis.call('SPOP', queueKey, toPop)
|
||||
@@ -252,17 +274,13 @@ return 1
|
||||
|
||||
const SCRIPT_SHA_CACHE = new Map<string, string>();
|
||||
|
||||
interface ScriptPurgeBatchResult {
|
||||
urls: Array<string>;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
export class KVClient implements IKVProvider {
|
||||
private readonly client: Redis | Cluster;
|
||||
private readonly config: ResolvedKVClientConfig;
|
||||
private readonly logger: IKVLogger;
|
||||
private readonly url: string;
|
||||
private readonly timeoutMs: number;
|
||||
private closed = false;
|
||||
|
||||
constructor(config: KVClientConfig | string) {
|
||||
const resolvedConfig = resolveKVClientConfig(config);
|
||||
@@ -283,13 +301,13 @@ export class KVClient implements IKVProvider {
|
||||
}
|
||||
|
||||
private createClusterClient(clusterConfig: ResolvedKVClientConfig): Cluster {
|
||||
const nodes =
|
||||
clusterConfig.clusterNodes.length > 0 ? clusterConfig.clusterNodes : parseClusterNodesFromUrl(clusterConfig.url);
|
||||
const {nodes, redisOptions} = resolveKVClusterConnection(clusterConfig.url, clusterConfig.clusterNodes);
|
||||
const natMap = clusterConfig.clusterNatMap;
|
||||
const hasNatMap = Object.keys(natMap).length > 0;
|
||||
return new Cluster(nodes, {
|
||||
clusterRetryStrategy: createRetryStrategy(),
|
||||
redisOptions: {
|
||||
...redisOptions,
|
||||
connectTimeout: clusterConfig.timeoutMs,
|
||||
commandTimeout: clusterConfig.timeoutMs,
|
||||
maxRetriesPerRequest: 1,
|
||||
@@ -299,11 +317,19 @@ export class KVClient implements IKVProvider {
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
this.client.disconnect(false);
|
||||
}
|
||||
|
||||
async health(): Promise<boolean> {
|
||||
try {
|
||||
return (await this.execute('health', async () => this.client.ping())) === 'PONG';
|
||||
} catch (error) {
|
||||
this.logger.debug({url: this.url, error}, 'KV health check failed');
|
||||
this.logger.debug({error}, 'KV health check failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -314,45 +340,38 @@ export class KVClient implements IKVProvider {
|
||||
|
||||
async set(key: string, value: string, ...args: Array<string | number>): Promise<string | null> {
|
||||
const options = parseSetArguments(args);
|
||||
if (options.useNx) {
|
||||
if (options.ttlSeconds !== undefined) {
|
||||
const ttlSeconds = options.ttlSeconds;
|
||||
return await this.execute('set', async () => {
|
||||
const result = await this.client.call('SET', key, value, 'EX', ttlSeconds, 'NX');
|
||||
return normalizeStringOrNull(result);
|
||||
});
|
||||
}
|
||||
return await this.execute('set', async () => {
|
||||
const result = await this.client.call('SET', key, value, 'NX');
|
||||
return normalizeStringOrNull(result);
|
||||
});
|
||||
}
|
||||
const modifiers: Array<string | number> = [];
|
||||
if (options.ttlSeconds !== undefined) {
|
||||
const ttlSeconds = options.ttlSeconds;
|
||||
return await this.execute('set', async () => {
|
||||
const result = await this.client.call('SET', key, value, 'EX', ttlSeconds);
|
||||
return normalizeStringOrNull(result);
|
||||
});
|
||||
modifiers.push('EX', options.ttlSeconds);
|
||||
}
|
||||
return await this.execute('set', async () => this.client.set(key, value));
|
||||
if (options.useNx) {
|
||||
modifiers.push('NX');
|
||||
}
|
||||
return await this.execute('set', async () => {
|
||||
const result =
|
||||
modifiers.length === 0
|
||||
? await this.client.set(key, value)
|
||||
: await this.client.call('SET', key, value, ...modifiers);
|
||||
return parseSetReply(result, 'set', options.useNx);
|
||||
});
|
||||
}
|
||||
|
||||
async setex(key: string, ttlSeconds: number, value: string): Promise<void> {
|
||||
await this.execute('setex', async () => {
|
||||
await this.client.setex(key, ttlSeconds, value);
|
||||
parseSetReply(await this.client.setex(key, ttlSeconds, value), 'setex');
|
||||
});
|
||||
}
|
||||
|
||||
async setnx(key: string, value: string, ttlSeconds?: number): Promise<boolean> {
|
||||
if (ttlSeconds !== undefined) {
|
||||
const ttlSecondsValue = ttlSeconds;
|
||||
const result = await this.execute('setnx', async () => {
|
||||
const commandResult = await this.client.call('SET', key, value, 'EX', ttlSecondsValue, 'NX');
|
||||
return normalizeStringOrNull(commandResult);
|
||||
const commandResult = await this.client.call('SET', key, value, 'EX', ttlSeconds, 'NX');
|
||||
return parseSetReply(commandResult, 'setnx', true);
|
||||
});
|
||||
return result === 'OK';
|
||||
}
|
||||
return (await this.execute('setnx', async () => this.client.setnx(key, value))) === 1;
|
||||
const result = await this.execute('setnx', async () => this.client.setnx(key, value));
|
||||
return parseIntegerDecision(result, 'setnx');
|
||||
}
|
||||
|
||||
async mget(...keys: Array<string>): Promise<Array<string | null>> {
|
||||
@@ -360,15 +379,18 @@ export class KVClient implements IKVProvider {
|
||||
return [];
|
||||
}
|
||||
return await this.execute('mget', async () => {
|
||||
const values = new Array<string | null>(keys.length).fill(null);
|
||||
const values = new Array<string | null>(keys.length);
|
||||
const batches = this.splitBySlot(
|
||||
keys.map((key, index) => ({key, index})),
|
||||
(entry) => entry.key,
|
||||
);
|
||||
await runSlotBatches(batches, async (batch) => {
|
||||
const batchValues = await this.client.mget(...batch.map((entry) => entry.key));
|
||||
if (!Array.isArray(batchValues) || batchValues.length !== batch.length) {
|
||||
throw createInvalidResponseError('mget', `${batch.length} values`);
|
||||
}
|
||||
for (const [position, entry] of batch.entries()) {
|
||||
values[entry.index] = batchValues[position] ?? null;
|
||||
values[entry.index] = parseNullableStringReply(batchValues[position], 'mget');
|
||||
}
|
||||
});
|
||||
return values;
|
||||
@@ -383,7 +405,8 @@ export class KVClient implements IKVProvider {
|
||||
await this.execute('mset', async () => {
|
||||
const batches = this.splitBySlot(entries, (entry) => entry.key);
|
||||
await runSlotBatches(batches, async (batch) => {
|
||||
await this.client.mset(...batch.flatMap((entry) => [entry.key, entry.value]));
|
||||
const result = await this.client.mset(...batch.flatMap((entry) => [entry.key, entry.value]));
|
||||
parseSetReply(result, 'mset');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -421,14 +444,14 @@ export class KVClient implements IKVProvider {
|
||||
async getex(key: string, ttlSeconds: number): Promise<string | null> {
|
||||
return await this.execute('getex', async () => {
|
||||
const result = await this.client.call('GETEX', key, 'EX', ttlSeconds);
|
||||
return normalizeStringOrNull(result);
|
||||
return parseNullableStringReply(result, 'getex');
|
||||
});
|
||||
}
|
||||
|
||||
async getdel(key: string): Promise<string | null> {
|
||||
return await this.execute('getdel', async () => {
|
||||
const result = await this.client.call('GETDEL', key);
|
||||
return normalizeStringOrNull(result);
|
||||
return parseNullableStringReply(result, 'getdel');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -464,10 +487,7 @@ export class KVClient implements IKVProvider {
|
||||
}
|
||||
return await this.execute('spop', async () => {
|
||||
const result = await this.client.spop(key, count);
|
||||
if (result === null) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(result) ? result : [result];
|
||||
return parsePoppedValues(result, 'spop', count);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -522,16 +542,9 @@ export class KVClient implements IKVProvider {
|
||||
}
|
||||
return await this.execute('lpop', async () => {
|
||||
if (count !== undefined) {
|
||||
const result = await this.client.call('LPOP', key, count);
|
||||
if (result === null) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(result)) {
|
||||
return result.map((entry) => String(entry));
|
||||
}
|
||||
return [String(result)];
|
||||
return parsePoppedValues(await this.client.lpop(key, count), 'lpop', count);
|
||||
}
|
||||
const single = await this.client.lpop(key);
|
||||
const single = parseNullableStringReply(await this.client.lpop(key), 'lpop');
|
||||
return single === null ? [] : [single];
|
||||
});
|
||||
}
|
||||
@@ -564,6 +577,7 @@ export class KVClient implements IKVProvider {
|
||||
}
|
||||
|
||||
duplicate(): IKVSubscription {
|
||||
this.assertOpen();
|
||||
return new KVSubscription({
|
||||
url: this.url,
|
||||
mode: this.config.mode,
|
||||
@@ -576,19 +590,19 @@ export class KVClient implements IKVProvider {
|
||||
async acquireLock(key: string, token: string, ttlSeconds: number): Promise<boolean> {
|
||||
const result = await this.execute('acquireLock', async () => {
|
||||
const commandResult = await this.client.call('SET', key, token, 'EX', ttlSeconds, 'NX');
|
||||
return normalizeStringOrNull(commandResult);
|
||||
return parseSetReply(commandResult, 'acquireLock', true);
|
||||
});
|
||||
return result === 'OK';
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
const result = await this.executeScript('releaseLock', RELEASE_LOCK_SCRIPT, 1, key, token);
|
||||
return Number(result) === 1;
|
||||
return parseIntegerDecision(result, 'releaseLock');
|
||||
}
|
||||
|
||||
async extendLock(key: string, token: string, ttlSeconds: number): Promise<boolean> {
|
||||
const result = await this.executeScript('extendLock', EXTEND_LOCK_SCRIPT, 1, key, token, ttlSeconds);
|
||||
return Number(result) === 1;
|
||||
return parseIntegerDecision(result, 'extendLock');
|
||||
}
|
||||
|
||||
async renewSnowflakeNode(key: string, instanceId: string, ttlSeconds: number): Promise<boolean> {
|
||||
@@ -600,20 +614,21 @@ export class KVClient implements IKVProvider {
|
||||
instanceId,
|
||||
ttlSeconds,
|
||||
);
|
||||
return Number(result) === 1;
|
||||
return parseIntegerDecision(result, 'renewSnowflakeNode');
|
||||
}
|
||||
|
||||
async checkLeakyBucketLimit(key: string, limit: number, windowMs: number, cost: number): Promise<KVRateLimitResult> {
|
||||
const now = Date.now();
|
||||
return await this.executeRateLimitScript(
|
||||
const result = await this.executeJsonScript(
|
||||
'checkLeakyBucketLimit',
|
||||
CHECK_LEAKY_BUCKET_LIMIT_SCRIPT,
|
||||
1,
|
||||
key,
|
||||
now,
|
||||
Date.now(),
|
||||
limit,
|
||||
windowMs,
|
||||
cost,
|
||||
);
|
||||
return parseRateLimitResult(result);
|
||||
}
|
||||
|
||||
async tryConsumeTokens(
|
||||
@@ -635,11 +650,14 @@ export class KVClient implements IKVProvider {
|
||||
refillRate,
|
||||
refillIntervalMs,
|
||||
);
|
||||
return Number(result);
|
||||
if (!isNonNegativeSafeInteger(result) || result > requested) {
|
||||
throw createInvalidResponseError('tryConsumeTokens', 'an integer token count within the requested amount');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void> {
|
||||
await this.executeScript(
|
||||
const result = await this.executeScript(
|
||||
'scheduleBulkDeletion',
|
||||
SCHEDULE_BULK_DELETION_SCRIPT,
|
||||
2,
|
||||
@@ -648,6 +666,7 @@ export class KVClient implements IKVProvider {
|
||||
score,
|
||||
value,
|
||||
);
|
||||
if (result !== 1) throw createInvalidResponseError('scheduleBulkDeletion', 'the acknowledgement 1');
|
||||
}
|
||||
|
||||
async claimBulkDeletion(queueKey: string, member: string, maxScore: number, leaseScore: number): Promise<boolean> {
|
||||
@@ -660,7 +679,7 @@ export class KVClient implements IKVProvider {
|
||||
maxScore,
|
||||
leaseScore,
|
||||
);
|
||||
return Number(result) === 1;
|
||||
return parseIntegerDecision(result, 'claimBulkDeletion');
|
||||
}
|
||||
|
||||
async removeBulkDeletion(queueKey: string, secondaryKey: string, member = ''): Promise<boolean> {
|
||||
@@ -672,7 +691,7 @@ export class KVClient implements IKVProvider {
|
||||
secondaryKey,
|
||||
member,
|
||||
);
|
||||
return Number(result) === 1;
|
||||
return parseIntegerDecision(result, 'removeBulkDeletion');
|
||||
}
|
||||
|
||||
async dequeuePurgeBatch(
|
||||
@@ -682,12 +701,9 @@ export class KVClient implements IKVProvider {
|
||||
maxTokens: number,
|
||||
refillRate: number,
|
||||
refillIntervalMs: number,
|
||||
): Promise<{
|
||||
urls: Array<string>;
|
||||
tokensConsumed: number;
|
||||
}> {
|
||||
): Promise<KVPurgeBatchResult> {
|
||||
const now = Date.now();
|
||||
const parsed = await this.executeJsonScript<ScriptPurgeBatchResult>(
|
||||
const result = await this.executeJsonScript(
|
||||
'dequeuePurgeBatch',
|
||||
DEQUEUE_PURGE_BATCH_SCRIPT,
|
||||
2,
|
||||
@@ -699,7 +715,7 @@ export class KVClient implements IKVProvider {
|
||||
refillRate,
|
||||
refillIntervalMs,
|
||||
);
|
||||
return {urls: parsed.urls, tokensConsumed: parsed.tokens};
|
||||
return parsePurgeBatchResult(result, maxItems);
|
||||
}
|
||||
|
||||
async evalScript(
|
||||
@@ -738,7 +754,10 @@ export class KVClient implements IKVProvider {
|
||||
|
||||
pipeline(): IKVPipeline {
|
||||
return new KVPipeline({
|
||||
createCommander: () => this.client.pipeline(),
|
||||
createCommander: () => {
|
||||
this.assertOpen();
|
||||
return this.client.pipeline();
|
||||
},
|
||||
normalizeError: (command, error) => this.normalizeError(command, error),
|
||||
mode: 'pipeline',
|
||||
});
|
||||
@@ -746,7 +765,10 @@ export class KVClient implements IKVProvider {
|
||||
|
||||
multi(): IKVPipeline {
|
||||
return new KVPipeline({
|
||||
createCommander: () => this.client.multi(),
|
||||
createCommander: () => {
|
||||
this.assertOpen();
|
||||
return this.client.multi();
|
||||
},
|
||||
normalizeError: (command, error) => this.normalizeError(command, error),
|
||||
mode: 'multi',
|
||||
});
|
||||
@@ -754,12 +776,19 @@ export class KVClient implements IKVProvider {
|
||||
|
||||
private async execute<T>(command: string, fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
this.assertOpen();
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
throw this.normalizeError(command, error);
|
||||
}
|
||||
}
|
||||
|
||||
private assertOpen(): void {
|
||||
if (this.closed) {
|
||||
throw new Error('KV client is closed');
|
||||
}
|
||||
}
|
||||
|
||||
private async executeScript(
|
||||
command: string,
|
||||
script: string,
|
||||
@@ -780,43 +809,21 @@ export class KVClient implements IKVProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private async executeJsonScript<T>(
|
||||
private async executeJsonScript(
|
||||
command: string,
|
||||
script: string,
|
||||
keyCount: number,
|
||||
...args: Array<string | number>
|
||||
): Promise<T> {
|
||||
): Promise<unknown> {
|
||||
const result = await this.executeScript(command, script, keyCount, ...args);
|
||||
if (typeof result !== 'string') {
|
||||
throw createInvalidResponseError(command, 'a JSON string');
|
||||
}
|
||||
try {
|
||||
return JSON.parse(String(result)) as T;
|
||||
} catch (error) {
|
||||
throw new KVClientError({
|
||||
code: KVClientErrorCode.INVALID_RESPONSE,
|
||||
message: `KV request returned invalid JSON (${command}): ${getErrorMessage(error)}`,
|
||||
});
|
||||
return JSON.parse(result);
|
||||
} catch {
|
||||
throw createInvalidResponseError(command, 'valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
private async executeRateLimitScript(
|
||||
command: string,
|
||||
script: string,
|
||||
key: string,
|
||||
nowMs: number,
|
||||
limit: number,
|
||||
windowMs: number,
|
||||
cost: number,
|
||||
): Promise<KVRateLimitResult> {
|
||||
const parsed = await this.executeJsonScript<KVRateLimitResult>(
|
||||
command,
|
||||
script,
|
||||
1,
|
||||
key,
|
||||
nowMs,
|
||||
limit,
|
||||
windowMs,
|
||||
cost,
|
||||
);
|
||||
return normalizeRateLimitResult(parsed);
|
||||
}
|
||||
|
||||
private normalizeError(command: string, error: unknown): KVClientError {
|
||||
@@ -836,18 +843,6 @@ export class KVClient implements IKVProvider {
|
||||
}
|
||||
}
|
||||
|
||||
function parseClusterNodesFromUrl(url: string): Array<{
|
||||
host: string;
|
||||
port: number;
|
||||
}> {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return [{host: parsed.hostname, port: Number.parseInt(parsed.port || '6379', 10)}];
|
||||
} catch {
|
||||
return [{host: '127.0.0.1', port: 6379}];
|
||||
}
|
||||
}
|
||||
|
||||
function createRetryStrategy(): (times: number) => number {
|
||||
return (times: number) => {
|
||||
const backoffMs = Math.min(times * 100, 2000);
|
||||
@@ -855,22 +850,77 @@ function createRetryStrategy(): (times: number) => number {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStringOrNull(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
return String(value);
|
||||
function parseNullableStringReply(value: unknown, command: string): string | null {
|
||||
if (value === null || typeof value === 'string') return value;
|
||||
throw createInvalidResponseError(command, 'a string or null');
|
||||
}
|
||||
|
||||
function normalizeRateLimitResult(result: KVRateLimitResult): KVRateLimitResult {
|
||||
return {
|
||||
allowed: Boolean(result.allowed),
|
||||
limit: Number(result.limit),
|
||||
remaining: Number(result.remaining),
|
||||
resetAfterMs: Number(result.resetAfterMs),
|
||||
resetAtMs: Number(result.resetAtMs),
|
||||
retryAfterMs: Number(result.retryAfterMs),
|
||||
};
|
||||
function parseSetReply(value: unknown, command: string, conditional = false): 'OK' | null {
|
||||
if (value === 'OK' || (conditional && value === null)) return value;
|
||||
throw createInvalidResponseError(command, conditional ? 'OK or null' : 'OK');
|
||||
}
|
||||
|
||||
function parsePoppedValues(value: unknown, command: string, count: number): Array<string> {
|
||||
if (value === null) return [];
|
||||
if (!Array.isArray(value) || value.length > count) {
|
||||
throw createInvalidResponseError(command, 'a string array within the requested count or null');
|
||||
}
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== 'string') throw createInvalidResponseError(command, 'string array entries');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseIntegerDecision(value: unknown, command: string): boolean {
|
||||
if (value === 0 || value === 1) return value === 1;
|
||||
throw createInvalidResponseError(command, 'the integer 0 or 1');
|
||||
}
|
||||
|
||||
function parseRateLimitResult(value: unknown): KVRateLimitResult {
|
||||
const command = 'checkLeakyBucketLimit';
|
||||
if (!isJsonObject(value)) throw createInvalidResponseError(command, 'a rate-limit result object');
|
||||
const {allowed, limit, remaining, resetAfterMs, resetAtMs, retryAfterMs} = value;
|
||||
if (
|
||||
typeof allowed !== 'boolean' ||
|
||||
!isNonNegativeSafeInteger(limit) ||
|
||||
limit === 0 ||
|
||||
!isNonNegativeSafeInteger(remaining) ||
|
||||
remaining > limit ||
|
||||
!isNonNegativeSafeInteger(resetAfterMs) ||
|
||||
typeof resetAtMs !== 'number' ||
|
||||
!Number.isSafeInteger(resetAtMs) ||
|
||||
Math.abs(resetAtMs) > MAX_DATE_TIMESTAMP ||
|
||||
!isNonNegativeSafeInteger(retryAfterMs) ||
|
||||
retryAfterMs > resetAfterMs ||
|
||||
(allowed ? retryAfterMs !== 0 : remaining !== 0 || retryAfterMs === 0)
|
||||
) {
|
||||
throw createInvalidResponseError(command, 'a boolean decision and valid integer rate-limit counts and times');
|
||||
}
|
||||
return {allowed, limit, remaining, resetAfterMs, resetAtMs, retryAfterMs};
|
||||
}
|
||||
|
||||
function parsePurgeBatchResult(value: unknown, maxItems: number): KVPurgeBatchResult {
|
||||
const command = 'dequeuePurgeBatch';
|
||||
if (!isJsonObject(value)) throw createInvalidResponseError(command, 'a purge batch object');
|
||||
const {urls, tokens} = value;
|
||||
if (
|
||||
!Array.isArray(urls) ||
|
||||
!urls.every((url): url is string => typeof url === 'string') ||
|
||||
!isNonNegativeSafeInteger(tokens) ||
|
||||
tokens !== urls.length ||
|
||||
urls.length > maxItems
|
||||
) {
|
||||
throw createInvalidResponseError(command, 'a bounded string array and matching token count');
|
||||
}
|
||||
return {urls, tokensConsumed: tokens};
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonNegativeSafeInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
function getScriptSha(script: string): string {
|
||||
|
||||
@@ -38,23 +38,14 @@ const noopLogger: IKVLogger = {
|
||||
};
|
||||
|
||||
export function resolveKVClientConfig(config: KVClientConfig | string): ResolvedKVClientConfig {
|
||||
if (typeof config === 'string') {
|
||||
return {
|
||||
url: normalizeUrl(config),
|
||||
mode: 'standalone' as const,
|
||||
clusterNodes: [],
|
||||
clusterNatMap: {},
|
||||
timeoutMs: DEFAULT_KV_TIMEOUT_MS,
|
||||
logger: noopLogger,
|
||||
};
|
||||
}
|
||||
const options: KVClientConfig = typeof config === 'string' ? {url: config} : config;
|
||||
return {
|
||||
url: normalizeUrl(config.url),
|
||||
mode: config.mode ?? 'standalone',
|
||||
clusterNodes: config.clusterNodes ?? [],
|
||||
clusterNatMap: config.clusterNatMap ?? {},
|
||||
timeoutMs: config.timeoutMs ?? DEFAULT_KV_TIMEOUT_MS,
|
||||
logger: config.logger ?? noopLogger,
|
||||
url: normalizeUrl(options.url),
|
||||
mode: options.mode ?? 'standalone',
|
||||
clusterNodes: options.clusterNodes ?? [],
|
||||
clusterNatMap: options.clusterNatMap ?? {},
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_KV_TIMEOUT_MS,
|
||||
logger: options.logger ?? noopLogger,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,5 +54,9 @@ function normalizeUrl(url: string): string {
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error('KV client URL must not be empty');
|
||||
}
|
||||
const query = new URLSearchParams(trimmed.match(/^[^?#]*(\?[^#]*)/)?.[1]);
|
||||
if (query.has('stringNumbers')) {
|
||||
throw new Error('KV client URLs do not support stringNumbers; numeric replies are required');
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@@ -26,3 +26,10 @@ export class KVClientError extends FluxerError {
|
||||
this.name = 'KVClientError';
|
||||
}
|
||||
}
|
||||
|
||||
export function createInvalidResponseError(command: string, expected: string): KVClientError {
|
||||
return new KVClientError({
|
||||
code: KVClientErrorCode.INVALID_RESPONSE,
|
||||
message: `KV request returned an invalid response (${command}): expected ${expected}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import {domainToASCII} from 'node:url';
|
||||
import type {KVClusterNode} from '@pkgs/kv_client/src/KVClientConfig';
|
||||
import type {RedisOptions} from 'ioredis';
|
||||
|
||||
interface KVClusterConnection {
|
||||
nodes: Array<KVClusterNode>;
|
||||
redisOptions: RedisOptions;
|
||||
}
|
||||
|
||||
export function resolveKVClusterConnection(url: string, nodes: ReadonlyArray<KVClusterNode>): KVClusterConnection {
|
||||
const normalizedUrl = url.trim();
|
||||
for (let index = 0; index < normalizedUrl.length; index++) {
|
||||
const code = normalizedUrl.charCodeAt(index);
|
||||
if (code <= 0x1f || code === 0x7f) {
|
||||
throw new Error('KV cluster URL must not contain control characters');
|
||||
}
|
||||
}
|
||||
const parsed = parseClusterUrl(normalizedUrl);
|
||||
const authorityStart = normalizedUrl.indexOf('://');
|
||||
if (authorityStart === -1) {
|
||||
throw new Error('Invalid KV cluster URL');
|
||||
}
|
||||
const authorityAndPath = normalizedUrl.slice(authorityStart + 3).split(/[?#]/, 1)[0];
|
||||
const pathStart = authorityAndPath.indexOf('/');
|
||||
const authority = pathStart === -1 ? authorityAndPath : authorityAndPath.slice(0, pathStart);
|
||||
const path = pathStart === -1 ? '' : authorityAndPath.slice(pathStart + 1);
|
||||
const queryNames = new Set<string>();
|
||||
for (const name of parsed.searchParams.keys()) {
|
||||
if (name !== 'username' && name !== 'password' && name !== 'db') {
|
||||
throw new Error('Unsupported KV cluster URL option');
|
||||
}
|
||||
if (queryNames.has(name)) {
|
||||
throw new Error('Duplicate KV cluster URL option');
|
||||
}
|
||||
queryNames.add(name);
|
||||
}
|
||||
if (path.length > 0) {
|
||||
validateClusterDatabase(path);
|
||||
}
|
||||
const database = parsed.searchParams.get('db');
|
||||
if (database !== null) {
|
||||
validateClusterDatabase(database);
|
||||
}
|
||||
const redisOptions = resolveClusterAuthentication(authority, parsed.searchParams);
|
||||
if (parsed.protocol === 'rediss:') {
|
||||
redisOptions.tls = {};
|
||||
}
|
||||
const host = resolveClusterHost(authority, parsed);
|
||||
const resolvedNodes = nodes.length > 0 ? [...nodes] : [{host, port: Number(parsed.port || '6379')}];
|
||||
for (const node of resolvedNodes) {
|
||||
if (node.host.trim().length === 0) {
|
||||
throw new Error('KV cluster node must include a host');
|
||||
}
|
||||
if (!Number.isInteger(node.port) || node.port < 1 || node.port > 65535) {
|
||||
throw new Error('KV cluster node port must be an integer between 1 and 65535');
|
||||
}
|
||||
}
|
||||
return {
|
||||
nodes: resolvedNodes,
|
||||
redisOptions,
|
||||
};
|
||||
}
|
||||
|
||||
function parseClusterUrl(url: string): URL {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error('Invalid KV cluster URL');
|
||||
}
|
||||
if (parsed.protocol !== 'redis:' && parsed.protocol !== 'rediss:') {
|
||||
throw new Error('KV cluster URL must use redis:// or rediss://');
|
||||
}
|
||||
if (!parsed.hostname) {
|
||||
throw new Error('KV cluster URL must include a host');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolveClusterHost(authority: string, parsed: URL): string {
|
||||
if (parsed.hostname.startsWith('[')) {
|
||||
return parsed.hostname.slice(1, -1);
|
||||
}
|
||||
const hostAndPort = authority.slice(authority.lastIndexOf('@') + 1);
|
||||
const portStart = hostAndPort.lastIndexOf(':');
|
||||
const hostname = portStart === -1 ? hostAndPort : hostAndPort.slice(0, portStart);
|
||||
if (hostname.includes('%')) {
|
||||
throw new Error('KV cluster URL must not contain a percent-encoded hostname');
|
||||
}
|
||||
const host = domainToASCII(hostname.toLowerCase());
|
||||
if (!host) {
|
||||
throw new Error('Invalid KV cluster URL hostname');
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function validateClusterDatabase(database: string): void {
|
||||
if (!/^0+$/.test(database)) {
|
||||
throw new Error('KV cluster URL must select database 0');
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClusterAuthentication(authority: string, query: URLSearchParams): RedisOptions {
|
||||
const authEnd = authority.lastIndexOf('@');
|
||||
let auth = '';
|
||||
if (authEnd !== -1) {
|
||||
try {
|
||||
auth = decodeURIComponent(authority.slice(0, authEnd));
|
||||
} catch {
|
||||
throw new Error('Invalid KV cluster URL credentials');
|
||||
}
|
||||
}
|
||||
if (auth) {
|
||||
const separator = auth.indexOf(':');
|
||||
return {
|
||||
username: separator === -1 ? auth : auth.slice(0, separator),
|
||||
password: separator === -1 ? '' : auth.slice(separator + 1),
|
||||
};
|
||||
}
|
||||
const options: RedisOptions = {};
|
||||
const username = query.get('username');
|
||||
const password = query.get('password');
|
||||
if (username !== null) {
|
||||
options.username = username;
|
||||
}
|
||||
if (password !== null) {
|
||||
options.password = password;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export function createZSetMembersFromScorePairs(scoreMembers: Array<number | str
|
||||
throw createInvalidArgumentError('zadd member must be a string');
|
||||
}
|
||||
members.push({
|
||||
score: parseFiniteNumber(rawScore, 'zadd score'),
|
||||
score: parseNumberArgument(rawScore, 'zadd score'),
|
||||
value: rawMember,
|
||||
});
|
||||
}
|
||||
@@ -136,14 +136,6 @@ function parseNumberArgument(value: string | number | undefined, label: string):
|
||||
throw createInvalidArgumentError(`${label} must be a finite number`);
|
||||
}
|
||||
|
||||
function parseFiniteNumber(value: string | number, label: string): number {
|
||||
const parsed = parseNumberArgument(value, label);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw createInvalidArgumentError(`${label} must be finite`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value: string | number | undefined, label: string): number {
|
||||
const parsed = parseNumberArgument(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
|
||||
@@ -16,10 +16,10 @@ function extractHashTag(key: string): string {
|
||||
}
|
||||
|
||||
export function computeHashSlot(key: string): number {
|
||||
const hashed = extractHashTag(key);
|
||||
const hashed = Buffer.from(extractHashTag(key));
|
||||
let crc = 0;
|
||||
for (let index = 0; index < hashed.length; index += 1) {
|
||||
crc ^= (hashed.charCodeAt(index) & 0xff) << 8;
|
||||
for (const byte of hashed) {
|
||||
crc ^= byte << 8;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc & 0x8000) === 0 ? (crc << 1) & 0xffff : ((crc << 1) ^ 0x1021) & 0xffff;
|
||||
}
|
||||
@@ -52,17 +52,17 @@ export function splitIntoSlotBatches<T>(
|
||||
}
|
||||
|
||||
export async function runSlotBatches<T>(batches: ReadonlyArray<T>, run: (batch: T) => Promise<void>): Promise<void> {
|
||||
if (batches.length <= MAX_CONCURRENT_SLOT_BATCHES) {
|
||||
await Promise.all(batches.map(async (batch) => await run(batch)));
|
||||
return;
|
||||
}
|
||||
let nextIndex = 0;
|
||||
const workers = Array.from({length: MAX_CONCURRENT_SLOT_BATCHES}, async () => {
|
||||
const workers = Array.from({length: Math.min(batches.length, MAX_CONCURRENT_SLOT_BATCHES)}, async () => {
|
||||
while (nextIndex < batches.length) {
|
||||
const batch = batches[nextIndex];
|
||||
nextIndex += 1;
|
||||
await run(batch);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
for (const result of await Promise.allSettled(workers)) {
|
||||
if (result.status === 'rejected') {
|
||||
throw result.reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IKVPipeline} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {createInvalidResponseError} from '@pkgs/kv_client/src/KVClientError';
|
||||
import {createStringEntriesFromPairs} from '@pkgs/kv_client/src/KVCommandArguments';
|
||||
import type {ChainableCommander} from 'ioredis';
|
||||
|
||||
type PipelineExecResult = [Error | null, unknown];
|
||||
type PipelineCommandReply = [Error] | PipelineExecResult;
|
||||
|
||||
interface KVPipelineOptions {
|
||||
createCommander: () => ChainableCommander;
|
||||
@@ -88,25 +90,27 @@ export class KVPipeline implements IKVPipeline {
|
||||
async exec(): Promise<Array<PipelineExecResult>> {
|
||||
const command = `${this.mode}.exec`;
|
||||
try {
|
||||
const rawResults = (await this.commander.exec()) as Array<PipelineExecResult> | null;
|
||||
const commander = this.commander;
|
||||
const expectedResults = commander.length - (this.mode === 'multi' ? 1 : 0);
|
||||
this.commander = this.createCommander();
|
||||
if (rawResults === null) {
|
||||
return [];
|
||||
const rawResults = await commander.exec();
|
||||
if (!Array.isArray(rawResults) || rawResults.length !== expectedResults) {
|
||||
throw createInvalidResponseError(command, `${expectedResults} command results`);
|
||||
}
|
||||
return rawResults.map((result: PipelineExecResult) => {
|
||||
const [error, value] = result;
|
||||
return [error ? normalizePipelineError(error) : null, value] as PipelineExecResult;
|
||||
return Array.from(rawResults, (result: unknown): PipelineExecResult => {
|
||||
if (!isPipelineCommandReply(result)) {
|
||||
throw createInvalidResponseError(command, 'an error/value pair for every command');
|
||||
}
|
||||
return [result[0], result[1]];
|
||||
});
|
||||
} catch (error) {
|
||||
this.commander = this.createCommander();
|
||||
throw this.normalizeError(command, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePipelineError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
return new Error(String(error));
|
||||
function isPipelineCommandReply(value: unknown): value is PipelineCommandReply {
|
||||
if (!Array.isArray(value)) return false;
|
||||
if (value[0] instanceof Error) return value.length === 1 || value.length === 2;
|
||||
return value.length === 2 && value[0] === null && value[1] !== undefined;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import type {IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import type {IKVLogger, KVClientMode, KVClusterNode} from '@pkgs/kv_client/src/KVClientConfig';
|
||||
import Redis from 'ioredis';
|
||||
import {resolveKVClusterConnection} from '@pkgs/kv_client/src/KVClusterConnection';
|
||||
import Redis, {type RedisOptions} from 'ioredis';
|
||||
|
||||
interface KVSubscriptionConfig {
|
||||
url: string;
|
||||
@@ -12,16 +13,23 @@ interface KVSubscriptionConfig {
|
||||
logger: IKVLogger;
|
||||
}
|
||||
|
||||
interface KVSubscriptionConnect {
|
||||
completion: Promise<void>;
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
export class KVSubscription implements IKVSubscription {
|
||||
private readonly url: string;
|
||||
private readonly mode: KVClientMode;
|
||||
private readonly clusterNodes: Array<KVClusterNode>;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly logger: IKVLogger;
|
||||
private readonly channels: Set<string> = new Set();
|
||||
private readonly desiredChannels = new Set<string>();
|
||||
private readonly messageCallbacks: Set<(channel: string, message: string) => void> = new Set();
|
||||
private readonly errorCallbacks: Set<(error: Error) => void> = new Set();
|
||||
private client: Redis | null = null;
|
||||
private connecting: KVSubscriptionConnect | null = null;
|
||||
private closing: Promise<void> | null = null;
|
||||
|
||||
constructor(config: KVSubscriptionConfig) {
|
||||
this.url = config.url;
|
||||
@@ -32,31 +40,125 @@ export class KVSubscription implements IKVSubscription {
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.client !== null) {
|
||||
this.assertNotClosing();
|
||||
if (this.connecting !== null) {
|
||||
return await this.connecting.completion;
|
||||
}
|
||||
if (this.client?.status === 'ready') {
|
||||
return;
|
||||
}
|
||||
const connectionUrl = this.resolveSubscriptionUrl();
|
||||
const client = new Redis(connectionUrl, {
|
||||
if (this.client === null || this.client.status === 'end') {
|
||||
this.client = this.createClient();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const connecting: KVSubscriptionConnect = {
|
||||
completion: this.connectClient(this.client, controller.signal),
|
||||
controller,
|
||||
};
|
||||
this.connecting = connecting;
|
||||
try {
|
||||
await connecting.completion;
|
||||
} finally {
|
||||
controller.abort();
|
||||
if (this.connecting === connecting) {
|
||||
this.connecting = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createClient(): Redis {
|
||||
const options: RedisOptions = {
|
||||
autoResubscribe: true,
|
||||
connectTimeout: this.timeoutMs,
|
||||
commandTimeout: this.timeoutMs,
|
||||
maxRetriesPerRequest: 1,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
});
|
||||
};
|
||||
const connection = this.mode === 'cluster' ? resolveKVClusterConnection(this.url, this.clusterNodes) : null;
|
||||
const client = connection
|
||||
? new Redis({...connection.redisOptions, ...connection.nodes[0], db: 0, ...options})
|
||||
: new Redis(this.url, options);
|
||||
client.on('message', (channel: string, message: string) => {
|
||||
if (this.client !== client || this.closing !== null) {
|
||||
return;
|
||||
}
|
||||
for (const callback of this.messageCallbacks) {
|
||||
callback(channel, message);
|
||||
}
|
||||
});
|
||||
client.on('error', (error: Error) => {
|
||||
if (this.client !== client || this.closing !== null) {
|
||||
return;
|
||||
}
|
||||
this.logger.error({error}, 'KV subscription error');
|
||||
for (const callback of this.errorCallbacks) {
|
||||
callback(error);
|
||||
}
|
||||
});
|
||||
this.client = client;
|
||||
if (this.channels.size > 0) {
|
||||
await this.client.subscribe(...Array.from(this.channels));
|
||||
return client;
|
||||
}
|
||||
|
||||
private async connectClient(client: Redis, signal: AbortSignal): Promise<void> {
|
||||
try {
|
||||
const ready = this.waitForReady(client, signal);
|
||||
if (client.status === 'wait') {
|
||||
await Promise.all([ready, client.connect()]);
|
||||
} else {
|
||||
await ready;
|
||||
}
|
||||
this.assertCurrentClient(client);
|
||||
if (this.desiredChannels.size > 0) {
|
||||
await client.subscribe(...this.desiredChannels);
|
||||
this.assertCurrentClient(client);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.client === client && this.closing === null) {
|
||||
this.client = null;
|
||||
client.disconnect(false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private waitForReady(client: Redis, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(signal.reason);
|
||||
}
|
||||
if (client.status === 'ready') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (error?: unknown): void => {
|
||||
clearTimeout(timer);
|
||||
client.off('ready', ready);
|
||||
client.off('end', ended);
|
||||
signal.removeEventListener('abort', aborted);
|
||||
if (error === undefined) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
const ready = (): void => finish();
|
||||
const ended = (): void => finish(new Error('KV subscription connection was closed'));
|
||||
const aborted = (): void => finish(signal.reason);
|
||||
const timer = setTimeout(() => finish(new Error('KV subscription connection timed out')), this.timeoutMs);
|
||||
timer.unref();
|
||||
client.once('ready', ready);
|
||||
client.once('end', ended);
|
||||
signal.addEventListener('abort', aborted, {once: true});
|
||||
});
|
||||
}
|
||||
|
||||
private assertNotClosing(): void {
|
||||
if (this.closing !== null) {
|
||||
throw new Error('KV subscription is closing');
|
||||
}
|
||||
}
|
||||
|
||||
private assertCurrentClient(client: Redis): void {
|
||||
if (this.client !== client || this.closing !== null) {
|
||||
throw new Error('KV subscription connection was closed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,39 +189,72 @@ export class KVSubscription implements IKVSubscription {
|
||||
}
|
||||
|
||||
async subscribe(...channels: Array<string>): Promise<void> {
|
||||
const newChannels = channels.filter((channel) => {
|
||||
if (this.channels.has(channel)) {
|
||||
return false;
|
||||
}
|
||||
this.channels.add(channel);
|
||||
return true;
|
||||
});
|
||||
if (newChannels.length === 0 || this.client === null) {
|
||||
this.assertNotClosing();
|
||||
const requestedChannels = [...new Set(channels)];
|
||||
for (const channel of requestedChannels) {
|
||||
this.desiredChannels.add(channel);
|
||||
}
|
||||
const client = this.client;
|
||||
if (requestedChannels.length === 0 || client === null) {
|
||||
return;
|
||||
}
|
||||
await this.client.subscribe(...newChannels);
|
||||
await client.subscribe(...requestedChannels);
|
||||
this.assertCurrentClient(client);
|
||||
}
|
||||
|
||||
async unsubscribe(...channels: Array<string>): Promise<void> {
|
||||
const removedChannels = channels.filter((channel) => this.channels.delete(channel));
|
||||
if (removedChannels.length === 0 || this.client === null) {
|
||||
this.assertNotClosing();
|
||||
const requestedChannels = [...new Set(channels)];
|
||||
for (const channel of requestedChannels) {
|
||||
this.desiredChannels.delete(channel);
|
||||
}
|
||||
const client = this.client;
|
||||
if (requestedChannels.length === 0 || client === null) {
|
||||
return;
|
||||
}
|
||||
await this.client.unsubscribe(...removedChannels);
|
||||
await client.unsubscribe(...requestedChannels);
|
||||
this.assertCurrentClient(client);
|
||||
}
|
||||
|
||||
async quit(): Promise<void> {
|
||||
if (this.closing !== null) {
|
||||
return await this.closing;
|
||||
}
|
||||
const client = this.client;
|
||||
this.client = null;
|
||||
if (client === null) {
|
||||
return;
|
||||
}
|
||||
await client.quit();
|
||||
const closing = Promise.resolve().then(() => this.closeClient(client));
|
||||
this.closing = closing;
|
||||
this.connecting?.controller.abort(new Error('KV subscription connection was closed'));
|
||||
try {
|
||||
await closing;
|
||||
} finally {
|
||||
if (this.client === client) {
|
||||
this.client = null;
|
||||
}
|
||||
this.connecting = null;
|
||||
if (this.closing === closing) {
|
||||
this.closing = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async closeClient(client: Redis): Promise<void> {
|
||||
try {
|
||||
if (this.client === client && client.status !== 'end') {
|
||||
await client.quit();
|
||||
}
|
||||
} finally {
|
||||
client.disconnect(false);
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
const client = this.client;
|
||||
this.client = null;
|
||||
this.connecting?.controller.abort(new Error('KV subscription connection was closed'));
|
||||
this.connecting = null;
|
||||
if (client === null) {
|
||||
return;
|
||||
}
|
||||
@@ -134,19 +269,8 @@ export class KVSubscription implements IKVSubscription {
|
||||
this.errorCallbacks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSubscriptionUrl(): string {
|
||||
if (this.mode !== 'cluster' || this.clusterNodes.length === 0) {
|
||||
return this.url;
|
||||
}
|
||||
const node = this.clusterNodes[0];
|
||||
return `redis://${node.host}:${node.port}`;
|
||||
}
|
||||
}
|
||||
|
||||
function createRetryStrategy(): (times: number) => number {
|
||||
return (times: number) => {
|
||||
const backoffMs = Math.min(times * 100, 2000);
|
||||
return backoffMs;
|
||||
};
|
||||
return (times) => Math.min(times * 100, 2000);
|
||||
}
|
||||
|
||||
@@ -2,30 +2,56 @@
|
||||
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import type {NatsConnectionOptions} from '@pkgs/nats/src/NatsConnectionOptions';
|
||||
import {connect, type NatsConnection} from 'nats';
|
||||
import {connect, ErrorCode, type NatsConnection, NatsError} from 'nats';
|
||||
|
||||
const DEFAULT_MAX_RECONNECT_ATTEMPTS = -1;
|
||||
const DEFAULT_RECONNECT_TIME_WAIT_MS = 500;
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 5000;
|
||||
const DEFAULT_DRAIN_TIMEOUT_MS = 5000;
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
||||
|
||||
interface NatsDrainDeadline {
|
||||
expired: boolean;
|
||||
promise: Promise<never>;
|
||||
}
|
||||
|
||||
export class NatsConnectionManager implements INatsConnectionManager {
|
||||
private connection: NatsConnection | null = null;
|
||||
private connectPromise: Promise<NatsConnection> | null = null;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private drainPromise: Promise<void> | null = null;
|
||||
private drainGeneration = 0;
|
||||
private readonly options: NatsConnectionOptions;
|
||||
private readonly drainTimeoutMs: number;
|
||||
|
||||
constructor(options: NatsConnectionOptions) {
|
||||
this.options = options;
|
||||
this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(this.drainTimeoutMs) ||
|
||||
this.drainTimeoutMs < 1 ||
|
||||
this.drainTimeoutMs > MAX_TIMER_DELAY_MS
|
||||
) {
|
||||
throw new RangeError(`NATS drainTimeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);
|
||||
}
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.assertNotDraining();
|
||||
if (this.connection !== null && !this.connection.isClosed()) {
|
||||
return;
|
||||
}
|
||||
if (this.connectPromise !== null) {
|
||||
this.connection = await this.connectPromise;
|
||||
return;
|
||||
const generation = this.drainGeneration;
|
||||
this.connectPromise ??= this.openConnection().finally(() => {
|
||||
this.connectPromise = null;
|
||||
});
|
||||
await this.connectPromise;
|
||||
if (generation !== this.drainGeneration) {
|
||||
throw NatsError.errorForCode(ErrorCode.ConnectionDraining);
|
||||
}
|
||||
const connectPromise = connect({
|
||||
}
|
||||
|
||||
private async openConnection(): Promise<void> {
|
||||
this.connection = await connect({
|
||||
servers: this.options.url,
|
||||
token: this.options.token || undefined,
|
||||
name: this.options.name,
|
||||
@@ -33,39 +59,88 @@ export class NatsConnectionManager implements INatsConnectionManager {
|
||||
reconnectTimeWait: this.options.reconnectTimeWaitMs ?? DEFAULT_RECONNECT_TIME_WAIT_MS,
|
||||
timeout: this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
});
|
||||
this.connectPromise = connectPromise;
|
||||
try {
|
||||
this.connection = await connectPromise;
|
||||
} finally {
|
||||
if (this.connectPromise === connectPromise) {
|
||||
this.connectPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getConnection(): NatsConnection {
|
||||
this.assertNotDraining();
|
||||
if (this.connection === null || this.connection.isClosed()) {
|
||||
throw new Error('NATS connection is not established. Call connect() first.');
|
||||
}
|
||||
return this.connection;
|
||||
}
|
||||
|
||||
async drain(): Promise<void> {
|
||||
if (this.connectPromise !== null) {
|
||||
drain(): Promise<void> {
|
||||
if (this.drainPromise !== null) {
|
||||
return this.drainPromise;
|
||||
}
|
||||
if (this.connection === null && this.connectPromise === null) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.drainGeneration++;
|
||||
const connectPromise = this.connectPromise;
|
||||
const timeout = Promise.withResolvers<never>();
|
||||
const deadline: NatsDrainDeadline = {expired: false, promise: timeout.promise};
|
||||
const timeoutError = new Error(`NATS connection drain exceeded ${this.drainTimeoutMs}ms`);
|
||||
const timer = setTimeout(() => {
|
||||
deadline.expired = true;
|
||||
timeout.reject(timeoutError);
|
||||
}, this.drainTimeoutMs);
|
||||
const cleanup = Promise.resolve()
|
||||
.then(() => this.drainConnection(connectPromise, deadline))
|
||||
.catch((error: unknown) => {
|
||||
if (deadline.expired && error !== timeoutError) {
|
||||
process.emitWarning(new Error('NATS connection cleanup failed after its drain deadline', {cause: error}));
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timer);
|
||||
this.drainPromise = null;
|
||||
});
|
||||
this.drainPromise = Promise.race([cleanup, deadline.promise]);
|
||||
return this.drainPromise;
|
||||
}
|
||||
|
||||
private async drainConnection(connectPromise: Promise<void> | null, deadline: NatsDrainDeadline): Promise<void> {
|
||||
if (connectPromise !== null) {
|
||||
try {
|
||||
this.connection = await this.connectPromise;
|
||||
await connectPromise;
|
||||
} catch {
|
||||
this.connectPromise = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.connection === null) {
|
||||
const connection = this.connection;
|
||||
if (connection === null) {
|
||||
return;
|
||||
}
|
||||
if (!this.connection.isClosed()) {
|
||||
await this.connection.drain();
|
||||
const errors: Array<unknown> = [];
|
||||
try {
|
||||
if (!deadline.expired && !connection.isClosed()) {
|
||||
await Promise.race([connection.drain(), deadline.promise]);
|
||||
if (!connection.isClosed()) {
|
||||
throw new Error('NATS connection remained open after draining.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
await connection.close();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
} finally {
|
||||
if (this.connection === connection) {
|
||||
this.connection = null;
|
||||
}
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, 'NATS connection drain and close failed');
|
||||
}
|
||||
|
||||
private assertNotDraining(): void {
|
||||
if (this.drainPromise !== null) {
|
||||
throw NatsError.errorForCode(ErrorCode.ConnectionDraining);
|
||||
}
|
||||
this.connection = null;
|
||||
}
|
||||
|
||||
isClosed(): boolean {
|
||||
|
||||
@@ -7,4 +7,5 @@ export interface NatsConnectionOptions {
|
||||
maxReconnectAttempts?: number;
|
||||
reconnectTimeWaitMs?: number;
|
||||
connectTimeoutMs?: number;
|
||||
drainTimeoutMs?: number;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {Pool, PoolClient, QueryResult, QueryResultRow} from 'pg';
|
||||
import pg from 'pg';
|
||||
|
||||
const MAX_DIAGNOSTIC_FIELD_LENGTH = 128;
|
||||
|
||||
interface PostgresConnectionDiagnostic {
|
||||
phase: 'idle' | 'checked_out';
|
||||
errorName: string;
|
||||
code?: string;
|
||||
severity?: string;
|
||||
routine?: string;
|
||||
}
|
||||
|
||||
type PostgresConnectionErrorReporter = (diagnostic: PostgresConnectionDiagnostic) => void;
|
||||
|
||||
interface AcquiredPostgresConnection {
|
||||
error: Error | null;
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface PostgresConfig {
|
||||
url?: string;
|
||||
host?: string;
|
||||
@@ -35,10 +53,14 @@ export interface IPostgresClient extends PostgresQueryable {
|
||||
|
||||
interface DefaultClientState {
|
||||
client: PostgresClient | null;
|
||||
initialization: Promise<void> | null;
|
||||
shutdown: Promise<void> | null;
|
||||
}
|
||||
|
||||
const defaultClientState: DefaultClientState = {
|
||||
client: null,
|
||||
initialization: null,
|
||||
shutdown: null,
|
||||
};
|
||||
|
||||
function assertIdentifier(identifier: string): string {
|
||||
@@ -53,6 +75,29 @@ function normalizePem(pem: string | undefined): string | undefined {
|
||||
return pem.replaceAll('\\n', '\n');
|
||||
}
|
||||
|
||||
function connectionDiagnostic(
|
||||
error: Error,
|
||||
phase: PostgresConnectionDiagnostic['phase'],
|
||||
): PostgresConnectionDiagnostic {
|
||||
return {
|
||||
phase,
|
||||
errorName: error.name.slice(0, MAX_DIAGNOSTIC_FIELD_LENGTH),
|
||||
...('code' in error && typeof error.code === 'string'
|
||||
? {code: error.code.slice(0, MAX_DIAGNOSTIC_FIELD_LENGTH)}
|
||||
: {}),
|
||||
...('severity' in error && typeof error.severity === 'string'
|
||||
? {severity: error.severity.slice(0, MAX_DIAGNOSTIC_FIELD_LENGTH)}
|
||||
: {}),
|
||||
...('routine' in error && typeof error.routine === 'string'
|
||||
? {routine: error.routine.slice(0, MAX_DIAGNOSTIC_FIELD_LENGTH)}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function reportConnectionError(diagnostic: PostgresConnectionDiagnostic): void {
|
||||
console.error('Postgres connection error', diagnostic);
|
||||
}
|
||||
|
||||
export function quoteIdentifier(identifier: string): string {
|
||||
return `"${assertIdentifier(identifier)}"`;
|
||||
}
|
||||
@@ -60,14 +105,32 @@ export function quoteIdentifier(identifier: string): string {
|
||||
class PostgresClient implements IPostgresClient {
|
||||
private readonly config: PostgresConfig;
|
||||
private pool: Pool | null;
|
||||
private connection: Promise<void> | null = null;
|
||||
private disconnection: Promise<void> | null = null;
|
||||
private activeOperations = 0;
|
||||
private resolveOperationsDrained: (() => void) | null = null;
|
||||
private readonly acquiredConnections = new WeakMap<PoolClient, AcquiredPostgresConnection>();
|
||||
|
||||
constructor(config: PostgresConfig) {
|
||||
constructor(
|
||||
config: PostgresConfig,
|
||||
private readonly onConnectionError: PostgresConnectionErrorReporter,
|
||||
) {
|
||||
this.config = {...config};
|
||||
this.pool = null;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.disconnection !== null) {
|
||||
throw new Error('Cannot connect Postgres while it is shutting down');
|
||||
}
|
||||
if (this.pool !== null) return;
|
||||
this.connection ??= this.openPool().finally(() => {
|
||||
this.connection = null;
|
||||
});
|
||||
await this.connection;
|
||||
}
|
||||
|
||||
private async openPool(): Promise<void> {
|
||||
const pool = new pg.Pool({
|
||||
connectionString: this.config.url || undefined,
|
||||
host: this.config.url ? undefined : (this.config.host ?? '127.0.0.1'),
|
||||
@@ -78,12 +141,72 @@ class PostgresClient implements IPostgresClient {
|
||||
ssl: this.config.ssl ? {rejectUnauthorized: true, ca: normalizePem(this.config.sslCa)} : undefined,
|
||||
max: this.config.maxConnections ?? 20,
|
||||
});
|
||||
const client = await pool.connect();
|
||||
client.release();
|
||||
this.observePoolConnections(pool);
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
let discardClient = true;
|
||||
try {
|
||||
const acquired = this.getAcquiredConnection(client);
|
||||
if (acquired.error) throw acquired.error;
|
||||
discardClient = false;
|
||||
} finally {
|
||||
client.release(discardClient);
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await pool.end();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], 'Postgres connection and pool cleanup failed');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
private observePoolConnections(pool: Pool): void {
|
||||
pool.on('error', (error) => this.onConnectionError(connectionDiagnostic(error, 'idle')));
|
||||
pool.on('acquire', (client) => {
|
||||
assert(!this.acquiredConnections.has(client), 'Postgres connection was acquired twice without release');
|
||||
const acquired: AcquiredPostgresConnection = {
|
||||
error: null,
|
||||
onError: (error) => {
|
||||
if (acquired.error !== null) return;
|
||||
acquired.error = error;
|
||||
this.onConnectionError(connectionDiagnostic(error, 'checked_out'));
|
||||
},
|
||||
};
|
||||
this.acquiredConnections.set(client, acquired);
|
||||
client.on('error', acquired.onError);
|
||||
});
|
||||
pool.on('release', (_error, client) => {
|
||||
const acquired = this.acquiredConnections.get(client);
|
||||
if (acquired) client.removeListener('error', acquired.onError);
|
||||
this.acquiredConnections.delete(client);
|
||||
});
|
||||
}
|
||||
|
||||
private getAcquiredConnection(client: PoolClient): AcquiredPostgresConnection {
|
||||
const acquired = this.acquiredConnections.get(client);
|
||||
assert(acquired, 'Postgres connection has no active acquisition');
|
||||
return acquired;
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.disconnection !== null) return this.disconnection;
|
||||
if (this.pool === null && this.connection === null) return;
|
||||
this.disconnection = this.closePool().finally(() => {
|
||||
this.disconnection = null;
|
||||
});
|
||||
await this.disconnection;
|
||||
}
|
||||
|
||||
private async closePool(): Promise<void> {
|
||||
await this.connection;
|
||||
if (this.activeOperations > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.resolveOperationsDrained = resolve;
|
||||
});
|
||||
}
|
||||
const pool = this.pool;
|
||||
if (pool === null) return;
|
||||
this.pool = null;
|
||||
@@ -91,7 +214,7 @@ class PostgresClient implements IPostgresClient {
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.pool !== null;
|
||||
return this.pool !== null && this.disconnection === null;
|
||||
}
|
||||
|
||||
async query<T extends QueryResultRow = QueryResultRow>(
|
||||
@@ -99,7 +222,7 @@ class PostgresClient implements IPostgresClient {
|
||||
values: Array<unknown> = [],
|
||||
name?: string,
|
||||
): Promise<QueryResult<T>> {
|
||||
return this.getPool().query<T>({text, values, name: this.statementName(name)});
|
||||
return this.withPool((pool) => pool.query<T>({text, values, name: this.statementName(name)}));
|
||||
}
|
||||
|
||||
private statementName(name: string | undefined): string | undefined {
|
||||
@@ -107,17 +230,38 @@ class PostgresClient implements IPostgresClient {
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (client: PostgresQueryable) => Promise<T>): Promise<T> {
|
||||
const client = await this.getPool().connect();
|
||||
return this.withPool((pool) => this.runTransaction(pool, fn));
|
||||
}
|
||||
|
||||
private async runTransaction<T>(pool: Pool, fn: (client: PostgresQueryable) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
let acquired: AcquiredPostgresConnection | null = null;
|
||||
let discardClient = false;
|
||||
try {
|
||||
acquired = this.getAcquiredConnection(client);
|
||||
if (acquired.error) throw acquired.error;
|
||||
await client.query('BEGIN');
|
||||
const result = await fn(poolClientQueryable(client, this.config.preparedStatements !== false));
|
||||
if (acquired.error) throw acquired.error;
|
||||
const result = await runTransactionCallback(client, acquired, this.config.preparedStatements !== false, fn);
|
||||
if (acquired.error) throw acquired.error;
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await rollback(client);
|
||||
if (acquired === null) throw error;
|
||||
if (!acquired.error) {
|
||||
try {
|
||||
await client.query('ROLLBACK');
|
||||
} catch (rollbackError) {
|
||||
discardClient = true;
|
||||
throw new AggregateError([error, rollbackError], 'Postgres transaction and rollback failed');
|
||||
}
|
||||
}
|
||||
if (acquired.error && acquired.error !== error) {
|
||||
throw new AggregateError([error, acquired.error], 'Postgres transaction and connection failed');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
client.release(discardClient || acquired === null || acquired.error !== null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +269,24 @@ class PostgresClient implements IPostgresClient {
|
||||
return this.config.kvTable ?? 'fluxer_kv';
|
||||
}
|
||||
|
||||
private async withPool<T>(operation: (pool: Pool) => Promise<T>): Promise<T> {
|
||||
const pool = this.getPool();
|
||||
this.activeOperations += 1;
|
||||
try {
|
||||
return await operation(pool);
|
||||
} finally {
|
||||
this.activeOperations -= 1;
|
||||
if (this.activeOperations === 0) {
|
||||
this.resolveOperationsDrained?.();
|
||||
this.resolveOperationsDrained = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getPool(): Pool {
|
||||
if (this.disconnection !== null) {
|
||||
throw new Error('Postgres client is shutting down');
|
||||
}
|
||||
if (this.pool === null) {
|
||||
throw new Error('Postgres client is not connected. Call connect() first.');
|
||||
}
|
||||
@@ -133,35 +294,76 @@ class PostgresClient implements IPostgresClient {
|
||||
}
|
||||
}
|
||||
|
||||
function poolClientQueryable(client: PoolClient, preparedStatements: boolean): PostgresQueryable {
|
||||
return {
|
||||
query: <T extends QueryResultRow = QueryResultRow>(text: string, values: Array<unknown> = [], name?: string) =>
|
||||
client.query<T>({text, values, name: preparedStatements ? name : undefined}),
|
||||
async function runTransactionCallback<T>(
|
||||
client: PoolClient,
|
||||
acquired: AcquiredPostgresConnection,
|
||||
preparedStatements: boolean,
|
||||
fn: (client: PostgresQueryable) => Promise<T>,
|
||||
): Promise<T> {
|
||||
let active = true;
|
||||
const queryable: PostgresQueryable = {
|
||||
query: async <TRow extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
values: Array<unknown> = [],
|
||||
name?: string,
|
||||
) => {
|
||||
if (!active) throw new Error('Postgres transaction callback has already completed');
|
||||
if (acquired.error) throw acquired.error;
|
||||
return client.query<TRow>({text, values, name: preparedStatements ? name : undefined});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function rollback(client: PoolClient): Promise<void> {
|
||||
try {
|
||||
await client.query('ROLLBACK');
|
||||
} catch {}
|
||||
return await fn(queryable);
|
||||
} finally {
|
||||
active = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initPostgres(config: PostgresConfig): Promise<void> {
|
||||
if (defaultClientState.client !== null) {
|
||||
await defaultClientState.client.shutdown();
|
||||
export async function initPostgres(
|
||||
config: PostgresConfig,
|
||||
onConnectionError: PostgresConnectionErrorReporter = reportConnectionError,
|
||||
): Promise<void> {
|
||||
if (defaultClientState.initialization !== null) {
|
||||
throw new Error('Postgres initialization is already in progress');
|
||||
}
|
||||
const client = new PostgresClient(config);
|
||||
if (defaultClientState.shutdown !== null) {
|
||||
throw new Error('Cannot initialize Postgres while it is shutting down');
|
||||
}
|
||||
const client = new PostgresClient(config, onConnectionError);
|
||||
defaultClientState.initialization = replaceDefaultClient(client).finally(() => {
|
||||
defaultClientState.initialization = null;
|
||||
});
|
||||
await defaultClientState.initialization;
|
||||
}
|
||||
|
||||
async function replaceDefaultClient(client: PostgresClient): Promise<void> {
|
||||
const previousClient = defaultClientState.client;
|
||||
defaultClientState.client = null;
|
||||
await previousClient?.shutdown();
|
||||
await client.connect();
|
||||
defaultClientState.client = client;
|
||||
}
|
||||
|
||||
export async function shutdownPostgres(): Promise<void> {
|
||||
if (defaultClientState.client === null) return;
|
||||
await defaultClientState.client.shutdown();
|
||||
if (defaultClientState.shutdown !== null) return defaultClientState.shutdown;
|
||||
if (defaultClientState.client === null && defaultClientState.initialization === null) return;
|
||||
defaultClientState.shutdown = closeDefaultClient().finally(() => {
|
||||
defaultClientState.shutdown = null;
|
||||
});
|
||||
await defaultClientState.shutdown;
|
||||
}
|
||||
|
||||
async function closeDefaultClient(): Promise<void> {
|
||||
await defaultClientState.initialization;
|
||||
const client = defaultClientState.client;
|
||||
defaultClientState.client = null;
|
||||
await client?.shutdown();
|
||||
}
|
||||
|
||||
export function getDefaultPostgresClient(): IPostgresClient {
|
||||
if (defaultClientState.shutdown !== null) {
|
||||
throw new Error('Default Postgres client is shutting down');
|
||||
}
|
||||
if (defaultClientState.client === null) {
|
||||
throw new Error('Postgres client is not initialized. Call initPostgres() first.');
|
||||
}
|
||||
|
||||
@@ -88,9 +88,8 @@ export class RateLimitService implements IRateLimitService {
|
||||
if (identifierPrefix.length === 0) {
|
||||
throw new Error('identifierPrefix must be non-empty');
|
||||
}
|
||||
const sentinelKey = this.keyFactory.getIdentifierKey(`${identifierPrefix}\x00`);
|
||||
const keyPrefix = sentinelKey.slice(0, -1);
|
||||
const pattern = `${keyPrefix}*`;
|
||||
const keyPrefix = this.keyFactory.getIdentifierKey(identifierPrefix);
|
||||
const pattern = `${keyPrefix.replace(/[\\*?[\]]/g, '\\$&')}*`;
|
||||
let totalDeleted = 0;
|
||||
const batchSize = 256;
|
||||
while (true) {
|
||||
|
||||
@@ -31,12 +31,10 @@ interface TwilioErrorResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface TwilioVerificationCheckResponse {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface TwilioVerificationStartResponse {
|
||||
channel?: string;
|
||||
interface TwilioResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
type TwilioCooldownScope = 'account' | 'phone' | 'account_and_phone';
|
||||
@@ -53,17 +51,17 @@ interface StartVerificationSentryContext extends Record<string, unknown> {
|
||||
}
|
||||
|
||||
interface TwilioLookupV2Response {
|
||||
valid?: boolean;
|
||||
country_code?: string;
|
||||
valid: boolean;
|
||||
country_code?: string | null;
|
||||
line_type_intelligence?: {
|
||||
type?: string;
|
||||
carrier_name?: string;
|
||||
error_code?: number;
|
||||
};
|
||||
type?: string | null;
|
||||
carrier_name?: string | null;
|
||||
error_code?: number | null;
|
||||
} | null;
|
||||
sms_pumping_risk?: {
|
||||
sms_pumping_risk_score?: number;
|
||||
error_code?: number;
|
||||
};
|
||||
sms_pumping_risk_score?: number | null;
|
||||
error_code?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TwilioSmsProviderConfig {
|
||||
@@ -82,6 +80,7 @@ interface TwilioSmsProviderDependencies {
|
||||
}
|
||||
|
||||
const DEFAULT_LOOKUP_TIMEOUT_MS = 3000;
|
||||
const VERIFY_TIMEOUT_MS = 10000;
|
||||
const KNOWN_LINE_TYPES: ReadonlySet<PhoneLineType> = new Set<PhoneLineType>([
|
||||
'mobile',
|
||||
'landline',
|
||||
@@ -97,7 +96,45 @@ const KNOWN_LINE_TYPES: ReadonlySet<PhoneLineType> = new Set<PhoneLineType>([
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
function normalizeLineType(raw: string | undefined): PhoneLineType | null {
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNullishString(value: unknown): value is string | null | undefined {
|
||||
return value == null || typeof value === 'string';
|
||||
}
|
||||
|
||||
function isNullishInteger(value: unknown): value is number | null | undefined {
|
||||
return value == null || (typeof value === 'number' && Number.isSafeInteger(value));
|
||||
}
|
||||
|
||||
function isTwilioLookupResponse(value: unknown): value is TwilioLookupV2Response {
|
||||
if (!isRecord(value) || typeof value.valid !== 'boolean' || !isNullishString(value.country_code)) return false;
|
||||
const line = value.line_type_intelligence;
|
||||
if (
|
||||
line != null &&
|
||||
(!isRecord(line) ||
|
||||
!isNullishString(line.type) ||
|
||||
!isNullishString(line.carrier_name) ||
|
||||
!isNullishInteger(line.error_code))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const risk = value.sms_pumping_risk;
|
||||
if (risk == null) return true;
|
||||
if (!isRecord(risk) || !isNullishInteger(risk.error_code)) return false;
|
||||
const score = risk.sms_pumping_risk_score;
|
||||
return isNullishInteger(score) && (score == null || (score >= 0 && score <= 100));
|
||||
}
|
||||
|
||||
function parseTwilioError(value: unknown): TwilioErrorResponse | null {
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.code !== undefined && (typeof value.code !== 'number' || !Number.isSafeInteger(value.code))) return null;
|
||||
if (value.message !== undefined && typeof value.message !== 'string') return null;
|
||||
return {code: value.code, message: value.message};
|
||||
}
|
||||
|
||||
function normalizeLineType(raw: string | null | undefined): PhoneLineType | null {
|
||||
if (!raw) return null;
|
||||
return KNOWN_LINE_TYPES.has(raw as PhoneLineType) ? (raw as PhoneLineType) : 'unknown';
|
||||
}
|
||||
@@ -185,7 +222,7 @@ export class TwilioSmsProvider implements ISmsProvider {
|
||||
requestBody[`RateLimits[${key}]`] = value;
|
||||
}
|
||||
}
|
||||
let response: Response;
|
||||
let response: TwilioResponse;
|
||||
try {
|
||||
response = await this.requestTwilio('Verifications', requestBody);
|
||||
} catch (error) {
|
||||
@@ -196,24 +233,23 @@ export class TwilioSmsProvider implements ISmsProvider {
|
||||
throw new SmsVerificationUnavailableError();
|
||||
}
|
||||
if (response.ok) {
|
||||
let parsed: TwilioVerificationStartResponse | null = null;
|
||||
try {
|
||||
parsed = (await response.json()) as TwilioVerificationStartResponse;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
phone: maskPhoneNumber(phone),
|
||||
channel: requestedChannel,
|
||||
},
|
||||
'[TwilioSmsProvider] Verification start response JSON parse failed',
|
||||
const parsed = response.body;
|
||||
if (
|
||||
!isRecord(parsed) ||
|
||||
!isNullishString(parsed.channel) ||
|
||||
(parsed.status !== undefined && parsed.status !== 'pending' && parsed.status !== 'approved')
|
||||
) {
|
||||
this.logger.error(
|
||||
{phone: maskPhoneNumber(phone), status: response.status},
|
||||
'[TwilioSmsProvider] Invalid verification start response',
|
||||
);
|
||||
throw new SmsVerificationUnavailableError();
|
||||
}
|
||||
return {
|
||||
channel: parsed?.channel ?? requestedChannel,
|
||||
channel: parsed.channel ?? requestedChannel,
|
||||
};
|
||||
}
|
||||
const body = await this.parseErrorBody(response);
|
||||
const body = parseTwilioError(response.body);
|
||||
if (body?.code === TWILIO_INVALID_PHONE_ERROR_CODE) {
|
||||
throw new InvalidPhoneNumberError();
|
||||
}
|
||||
@@ -230,7 +266,7 @@ export class TwilioSmsProvider implements ISmsProvider {
|
||||
}
|
||||
|
||||
async checkVerification(phone: string, code: string): Promise<boolean> {
|
||||
let response: Response;
|
||||
let response: TwilioResponse;
|
||||
try {
|
||||
response = await this.requestTwilio('VerificationCheck', {
|
||||
To: phone,
|
||||
@@ -244,7 +280,7 @@ export class TwilioSmsProvider implements ISmsProvider {
|
||||
throw new SmsVerificationUnavailableError();
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = await this.parseErrorBody(response);
|
||||
const body = parseTwilioError(response.body);
|
||||
const rateLimitError = this.createRateLimitError(response, body, 'VerificationCheck');
|
||||
if (rateLimitError) {
|
||||
throw rateLimitError;
|
||||
@@ -263,21 +299,26 @@ export class TwilioSmsProvider implements ISmsProvider {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const body = (await response.json()) as TwilioVerificationCheckResponse;
|
||||
const body = response.body;
|
||||
if (!isRecord(body) || typeof body.status !== 'string') {
|
||||
this.logger.error(
|
||||
{phone: maskPhoneNumber(phone), status: response.status},
|
||||
'[TwilioSmsProvider] Invalid verification check response',
|
||||
);
|
||||
throw new SmsVerificationUnavailableError();
|
||||
}
|
||||
return body.status === 'approved';
|
||||
}
|
||||
|
||||
async lookupPhone(phone: string): Promise<PhoneLookupResult | null> {
|
||||
const url = `${this.lookupApiUrl}/PhoneNumbers/${encodeURIComponent(phone)}?Fields=line_type_intelligence,sms_pumping_risk`;
|
||||
const auth = Buffer.from(`${this.config.accountSid}:${this.config.authToken}`).toString('base64');
|
||||
const abort = new AbortController();
|
||||
const timer = setTimeout(() => abort.abort(), this.lookupTimeoutMs);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchFn(url, {
|
||||
method: 'GET',
|
||||
headers: {Authorization: `Basic ${auth}`},
|
||||
signal: abort.signal,
|
||||
signal: AbortSignal.timeout(this.lookupTimeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
@@ -285,10 +326,14 @@ export class TwilioSmsProvider implements ISmsProvider {
|
||||
'[TwilioSmsProvider] Lookup request failed (fail-open)',
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
await response.body?.cancel().catch(() => {
|
||||
this.logger.warn(
|
||||
{status: response.status},
|
||||
'[TwilioSmsProvider] Failed to cancel discarded lookup response body',
|
||||
);
|
||||
});
|
||||
return {
|
||||
valid: false,
|
||||
lineType: null,
|
||||
@@ -310,9 +355,9 @@ export class TwilioSmsProvider implements ISmsProvider {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
let parsed: TwilioLookupV2Response;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = (await response.json()) as TwilioLookupV2Response;
|
||||
parsed = await response.json();
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{error: error instanceof Error ? error.message : String(error), phone: maskPhoneNumber(phone)},
|
||||
@@ -320,6 +365,10 @@ export class TwilioSmsProvider implements ISmsProvider {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!isTwilioLookupResponse(parsed)) {
|
||||
this.logger.warn({phone: maskPhoneNumber(phone)}, '[TwilioSmsProvider] Invalid lookup response (fail-open)');
|
||||
return null;
|
||||
}
|
||||
const countryCode = parsed.country_code ?? null;
|
||||
const carrierName = parsed.line_type_intelligence?.carrier_name ?? null;
|
||||
const ltiErrorCode = parsed.line_type_intelligence?.error_code ?? null;
|
||||
@@ -336,36 +385,40 @@ export class TwilioSmsProvider implements ISmsProvider {
|
||||
'[TwilioSmsProvider] Lookup sms_pumping_risk reported error_code',
|
||||
);
|
||||
}
|
||||
const rawScore = parsed.sms_pumping_risk?.sms_pumping_risk_score;
|
||||
const smsPumpingRiskScore = typeof rawScore === 'number' && Number.isFinite(rawScore) ? rawScore : null;
|
||||
return {
|
||||
valid: parsed.valid !== false,
|
||||
valid: parsed.valid,
|
||||
lineType: normalizeLineType(parsed.line_type_intelligence?.type),
|
||||
countryCode,
|
||||
carrierName,
|
||||
smsPumpingRiskScore,
|
||||
smsPumpingRiskScore: parsed.sms_pumping_risk?.sms_pumping_risk_score ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async requestTwilio(
|
||||
endpoint: 'Verifications' | 'VerificationCheck',
|
||||
body: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
): Promise<TwilioResponse> {
|
||||
const url = `${this.verifyApiUrl}/Services/${this.config.verifyServiceSid}/${endpoint}`;
|
||||
const auth = Buffer.from(`${this.config.accountSid}:${this.config.authToken}`).toString('base64');
|
||||
return this.fetchFn(url, {
|
||||
const response = await this.fetchFn(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(body).toString(),
|
||||
signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
|
||||
});
|
||||
const parsed: unknown = await response.json().catch((error: unknown) => {
|
||||
if (response.ok) throw error;
|
||||
return null;
|
||||
});
|
||||
return {ok: response.ok, status: response.status, body: parsed};
|
||||
}
|
||||
|
||||
private async parseErrorBody(response: Response): Promise<TwilioErrorResponse | null> {
|
||||
try {
|
||||
return (await response.json()) as TwilioErrorResponse;
|
||||
return parseTwilioError(await response.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {WorkerJobOptions, WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
|
||||
|
||||
export interface WorkerTaskAttempt {
|
||||
readonly isLastAttempt: boolean;
|
||||
}
|
||||
|
||||
export interface WorkerTaskHelpers {
|
||||
logger: LoggerInterface;
|
||||
jobId: bigint;
|
||||
attempt?: WorkerTaskAttempt;
|
||||
addJob: <TPayload extends WorkerJobPayload = WorkerJobPayload>(
|
||||
taskType: string,
|
||||
payload: TPayload,
|
||||
|
||||
@@ -24,37 +24,33 @@ export class HttpWorkerQueue implements IQueueProvider {
|
||||
this.tracing = options.tracing;
|
||||
}
|
||||
|
||||
private createTimeoutController(): AbortController {
|
||||
private async withResponse<T>(
|
||||
input: string | URL,
|
||||
init: RequestInit,
|
||||
consume: (response: Response) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
(
|
||||
controller as {
|
||||
timeoutId?: NodeJS.Timeout;
|
||||
}
|
||||
).timeoutId = timeoutId;
|
||||
return controller;
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(input: string | URL, init?: RequestInit): Promise<Response> {
|
||||
const controller = this.createTimeoutController();
|
||||
try {
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response;
|
||||
return await consume(response);
|
||||
} finally {
|
||||
const timeoutId = (
|
||||
controller as {
|
||||
timeoutId?: NodeJS.Timeout;
|
||||
}
|
||||
).timeoutId;
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
private async requireSuccess(response: Response, action: string): Promise<void> {
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to ${action}: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
private async withOptionalSpan<T>(
|
||||
options: {
|
||||
name: string;
|
||||
@@ -100,20 +96,22 @@ export class HttpWorkerQueue implements IQueueProvider {
|
||||
run_at: options?.runAt?.toISOString(),
|
||||
max_attempts: options?.maxAttempts ?? 5,
|
||||
};
|
||||
const response = await this.fetchWithTimeout(`${this.baseUrl}/enqueue`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to enqueue job: ${response.status} ${text}`);
|
||||
}
|
||||
const jobIdResult = (await response.json()) as {
|
||||
job_id: string;
|
||||
};
|
||||
this.setSpanAttributes({'queue.job_id': jobIdResult.job_id});
|
||||
return jobIdResult.job_id;
|
||||
return this.withResponse(
|
||||
`${this.baseUrl}/enqueue`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
async (response) => {
|
||||
await this.requireSuccess(response, 'enqueue job');
|
||||
const jobIdResult = (await response.json()) as {
|
||||
job_id: string;
|
||||
};
|
||||
this.setSpanAttributes({'queue.job_id': jobIdResult.job_id});
|
||||
return jobIdResult.job_id;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -134,34 +132,32 @@ export class HttpWorkerQueue implements IQueueProvider {
|
||||
url.searchParams.set('task_types', taskTypes.join(','));
|
||||
url.searchParams.set('limit', limit.toString());
|
||||
url.searchParams.set('wait_time_ms', '0');
|
||||
const response = await this.fetchWithTimeout(url.toString(), {method: 'GET'});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to dequeue job: ${response.status} ${text}`);
|
||||
}
|
||||
this.addSpanEvent('dequeue.parse_response');
|
||||
const jobs = (await response.json()) as Array<LeasedQueueJob>;
|
||||
const jobCount = jobs?.length ?? 0;
|
||||
this.setSpanAttributes({
|
||||
'queue.jobs_returned': jobCount,
|
||||
'queue.empty': jobCount === 0,
|
||||
return this.withResponse(url, {method: 'GET'}, async (response) => {
|
||||
await this.requireSuccess(response, 'dequeue job');
|
||||
this.addSpanEvent('dequeue.parse_response');
|
||||
const jobs = (await response.json()) as Array<LeasedQueueJob>;
|
||||
const jobCount = jobs?.length ?? 0;
|
||||
this.setSpanAttributes({
|
||||
'queue.jobs_returned': jobCount,
|
||||
'queue.empty': jobCount === 0,
|
||||
});
|
||||
this.addSpanEvent('dequeue.complete');
|
||||
return jobs ?? [];
|
||||
});
|
||||
this.addSpanEvent('dequeue.complete');
|
||||
return jobs ?? [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async upsertCron(id: string, taskType: string, payload: WorkerJobPayload, cronExpression: string): Promise<void> {
|
||||
const response = await this.fetchWithTimeout(`${this.baseUrl}/cron`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id, task_type: taskType, payload, cron_expression: cronExpression}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to upsert cron job: ${response.status} ${text}`);
|
||||
}
|
||||
await this.withResponse(
|
||||
`${this.baseUrl}/cron`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id, task_type: taskType, payload, cron_expression: cronExpression}),
|
||||
},
|
||||
(response) => this.requireSuccess(response, 'upsert cron job'),
|
||||
);
|
||||
}
|
||||
|
||||
async complete(receipt: string): Promise<void> {
|
||||
@@ -172,17 +168,16 @@ export class HttpWorkerQueue implements IQueueProvider {
|
||||
'queue.receipt': receipt,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const response = await this.fetchWithTimeout(`${this.baseUrl}/ack`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({receipt}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to complete job: ${response.status} ${text}`);
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
this.withResponse(
|
||||
`${this.baseUrl}/ack`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({receipt}),
|
||||
},
|
||||
(response) => this.requireSuccess(response, 'complete job'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,48 +190,45 @@ export class HttpWorkerQueue implements IQueueProvider {
|
||||
'queue.error_message': error,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const response = await this.fetchWithTimeout(`${this.baseUrl}/nack`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({receipt, error}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to fail job: ${response.status} ${text}`);
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
this.withResponse(
|
||||
`${this.baseUrl}/nack`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({receipt, error}),
|
||||
},
|
||||
(response) => this.requireSuccess(response, 'fail job'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async cancelJob(jobId: string): Promise<boolean> {
|
||||
const response = await this.fetchWithTimeout(`${this.baseUrl}/job/${jobId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
return this.withResponse(`${this.baseUrl}/job/${jobId}`, {method: 'DELETE'}, async (response) => {
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Failed to cancel job: ${response.status} ${text}`);
|
||||
}
|
||||
throw new Error(`Failed to cancel job: ${response.status} ${text}`);
|
||||
}
|
||||
const result = (await response.json()) as {
|
||||
success: boolean;
|
||||
};
|
||||
return result.success ?? true;
|
||||
const result = (await response.json()) as {
|
||||
success: boolean;
|
||||
};
|
||||
return result.success ?? true;
|
||||
});
|
||||
}
|
||||
|
||||
async retryDeadLetterJob(jobId: string): Promise<boolean> {
|
||||
const response = await this.fetchWithTimeout(`${this.baseUrl}/retry/${jobId}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
return this.withResponse(`${this.baseUrl}/retry/${jobId}`, {method: 'POST'}, async (response) => {
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Failed to retry job: ${response.status} ${text}`);
|
||||
}
|
||||
throw new Error(`Failed to retry job: ${response.status} ${text}`);
|
||||
}
|
||||
return true;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user