refactor(api): tidy the api and shared packages (#2733)

This commit is contained in:
Hampus
2026-09-13 17:37:48 +02:00
committed by GitHub
parent 580401d2dc
commit 5afbf67a70
333 changed files with 10180 additions and 7910 deletions
+2 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+9 -1
View File
@@ -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) {
+1
View File
@@ -18,6 +18,7 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"undici-types": "catalog:",
"vitest": "catalog:"
}
}
+101 -68
View File
@@ -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/**/*"]
}
+6 -4
View File
@@ -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;
+194 -144
View File
@@ -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 {
+11 -16
View File
@@ -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) {
+9 -9
View File
@@ -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;
}
}
}
+16 -12
View File
@@ -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;
}
+159 -35
View File
@@ -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;
}
+226 -24
View File
@@ -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;
});
}
}