mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
refactor(api): tidy the api and shared packages (#2733)
This commit is contained in:
1 parent
580401d2dc
commit
5afbf67a70
333 files changed
+10180
-7910
No files matched your search
@@ -1,6 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ms} from 'itty-time';
|
||||
import {requireIntegerInRange} from './IntegerOptions';
|
||||
import {getValidTimestamp} from './TimestampUtils';
|
||||
|
||||
export interface AttachmentDecayRules {
|
||||
minMb: number;
|
||||
@@ -47,6 +49,11 @@ function toMb(sizeBytes: bigint | number): number {
|
||||
return n / 1024 / 1024;
|
||||
}
|
||||
|
||||
function logRatio(value: number, base: number): number {
|
||||
const ratio = value / base;
|
||||
return Number.isFinite(ratio) ? Math.log(ratio) : Math.log(value) - Math.log(base);
|
||||
}
|
||||
|
||||
export function computeDecay({sizeBytes, uploadedAt, rules}: AttachmentDecayInput): AttachmentDecayResult | null {
|
||||
const constants = {
|
||||
minMb: rules?.minMb ?? DEFAULT_DECAY_CONSTANTS.MIN_MB,
|
||||
@@ -66,18 +73,16 @@ export function computeDecay({sizeBytes, uploadedAt, rules}: AttachmentDecayInpu
|
||||
lifetimeDays = constants.minDays;
|
||||
} else {
|
||||
const linearFrac = (sizeMB - constants.minMb) / (constants.maxMb - constants.minMb);
|
||||
const logFrac = Math.log(sizeMB / constants.minMb) / Math.log(constants.maxMb / constants.minMb);
|
||||
const logFrac = logRatio(sizeMB, constants.minMb) / logRatio(constants.maxMb, constants.minMb);
|
||||
const blend = (1 - constants.curve) * linearFrac + constants.curve * logFrac;
|
||||
lifetimeDays = constants.maxDays - blend * (constants.maxDays - constants.minDays);
|
||||
}
|
||||
const expiresAt = new Date(uploadedAt);
|
||||
const expiresAt = new Date(getValidTimestamp(uploadedAt, 'Attachment upload date'));
|
||||
expiresAt.setUTCDate(expiresAt.getUTCDate() + lifetimeDays);
|
||||
const sizeTB = (typeof sizeBytes === 'bigint' ? Number(sizeBytes) : sizeBytes) / 1024 / 1024 / 1024 / 1024;
|
||||
const lifetimeMonths = lifetimeDays / 30;
|
||||
const cost = sizeTB * constants.pricePerTBPerMonth * lifetimeMonths;
|
||||
getValidTimestamp(expiresAt, 'Attachment decay expiry');
|
||||
return {
|
||||
expiresAt,
|
||||
cost,
|
||||
cost: computeCost({sizeBytes, lifetimeDays, pricePerTBPerMonth: constants.pricePerTBPerMonth}),
|
||||
days: Math.round(lifetimeDays),
|
||||
};
|
||||
}
|
||||
@@ -99,14 +104,17 @@ export function computeCost({
|
||||
}
|
||||
|
||||
export function getExpiryBucket(expiresAt: Date): number {
|
||||
return Number(
|
||||
getValidTimestamp(expiresAt, 'Attachment expiry');
|
||||
const bucket = Number(
|
||||
`${expiresAt.getUTCFullYear()}${String(expiresAt.getUTCMonth() + 1).padStart(2, '0')}${String(expiresAt.getUTCDate()).padStart(2, '0')}`,
|
||||
);
|
||||
return requireIntegerInRange('Attachment expiry bucket', bucket, -2_147_483_648, 2_147_483_647);
|
||||
}
|
||||
|
||||
export function extendExpiry(currentExpiry: Date | null, newlyComputed: Date): Date {
|
||||
const newTimestamp = getValidTimestamp(newlyComputed, 'Computed attachment expiry');
|
||||
if (!currentExpiry) return newlyComputed;
|
||||
return currentExpiry > newlyComputed ? currentExpiry : newlyComputed;
|
||||
return getValidTimestamp(currentExpiry, 'Current attachment expiry') > newTimestamp ? currentExpiry : newlyComputed;
|
||||
}
|
||||
|
||||
export function maybeRenewExpiry({
|
||||
@@ -124,16 +132,19 @@ export function maybeRenewExpiry({
|
||||
}): Date | null {
|
||||
if (!currentExpiry) return null;
|
||||
if (windowDays <= 0) return null;
|
||||
const remainingMs = currentExpiry.getTime() - now.getTime();
|
||||
const currentTimestamp = getValidTimestamp(currentExpiry, 'Current attachment expiry');
|
||||
const nowTimestamp = getValidTimestamp(now, 'Attachment renewal time');
|
||||
const remainingMs = currentTimestamp - nowTimestamp;
|
||||
if (remainingMs > thresholdDays * MS_PER_DAY) {
|
||||
return null;
|
||||
}
|
||||
const targetMs = now.getTime() + windowDays * MS_PER_DAY;
|
||||
const cappedTargetMs = maxExpiry ? Math.min(maxExpiry.getTime(), targetMs) : targetMs;
|
||||
if (cappedTargetMs <= currentExpiry.getTime()) {
|
||||
const targetMs = nowTimestamp + windowDays * MS_PER_DAY;
|
||||
const cappedTargetMs = maxExpiry
|
||||
? Math.min(getValidTimestamp(maxExpiry, 'Maximum attachment expiry'), targetMs)
|
||||
: targetMs;
|
||||
if (cappedTargetMs <= currentTimestamp) {
|
||||
return null;
|
||||
}
|
||||
const target = new Date(now);
|
||||
target.setTime(cappedTargetMs);
|
||||
return target;
|
||||
const target = new Date(cappedTargetMs);
|
||||
return getValidTimestamp(target, 'Renewed attachment expiry') > currentTimestamp ? target : null;
|
||||
}
|
||||
@@ -2,6 +2,35 @@
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
export async function awaitAll(tasks: ReadonlyArray<Promise<unknown>>, failureMessage: string): Promise<void> {
|
||||
const results = await Promise.allSettled(tasks);
|
||||
const errors: Array<unknown> = [];
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') errors.push(result.reason);
|
||||
}
|
||||
throwCompletionErrors(errors, failureMessage);
|
||||
}
|
||||
|
||||
export async function runAllInOrder(
|
||||
steps: ReadonlyArray<() => Promise<unknown>>,
|
||||
failureMessage: string,
|
||||
): Promise<void> {
|
||||
const errors: Array<unknown> = [];
|
||||
for (const step of steps) {
|
||||
try {
|
||||
await step();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
throwCompletionErrors(errors, failureMessage);
|
||||
}
|
||||
|
||||
function throwCompletionErrors(errors: ReadonlyArray<unknown>, failureMessage: string): void {
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, failureMessage);
|
||||
}
|
||||
|
||||
export async function mapWithConcurrency<T, TResult>(
|
||||
items: ReadonlyArray<T>,
|
||||
concurrency: number,
|
||||
@@ -10,13 +39,18 @@ export async function mapWithConcurrency<T, TResult>(
|
||||
assert(Number.isSafeInteger(concurrency) && concurrency > 0, 'Concurrency must be a positive safe integer');
|
||||
const results = new Array<TResult>(items.length);
|
||||
let nextIndex = 0;
|
||||
let failure: {error: unknown} | undefined;
|
||||
async function worker(): Promise<void> {
|
||||
for (;;) {
|
||||
while (!failure && nextIndex < items.length) {
|
||||
const index = nextIndex++;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await mapper(items[index]!, index);
|
||||
try {
|
||||
results[index] = await mapper(items[index]!, index);
|
||||
} catch (error) {
|
||||
failure ??= {error};
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, () => worker()));
|
||||
if (failure) throw failure.error;
|
||||
return results;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHttpClient} from '@pkgs/http_client/src/HttpClient';
|
||||
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
|
||||
import {DEFAULT_MAX_REDIRECTS, normalizeMaxRedirects} from '@pkgs/http_client/src/HttpClientRequestInternals';
|
||||
import type {
|
||||
HttpClient,
|
||||
RequestOptions,
|
||||
@@ -9,13 +11,14 @@ import type {
|
||||
StreamResponse,
|
||||
} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
|
||||
import {Logger} from '../Logger';
|
||||
|
||||
const requestUrlPolicy = createPublicInternetRequestUrlPolicy();
|
||||
const client: HttpClient = createHttpClient({
|
||||
userAgent: 'fluxer-api',
|
||||
requestUrlPolicy,
|
||||
});
|
||||
const scopedClients = new Map<RequestUrlPolicy, Map<number, HttpClient>>();
|
||||
const scopedClients = new WeakMap<RequestUrlPolicy, Map<number, HttpClient>>();
|
||||
|
||||
interface SendRequestOptions {
|
||||
maxRedirects?: number;
|
||||
@@ -24,8 +27,8 @@ interface SendRequestOptions {
|
||||
|
||||
function getHttpClientForRequest(options?: SendRequestOptions): HttpClient {
|
||||
const policy = options?.requestUrlPolicy ?? requestUrlPolicy;
|
||||
const maxRedirects = options?.maxRedirects ?? 0;
|
||||
if (policy === requestUrlPolicy && maxRedirects === 0) {
|
||||
const maxRedirects = normalizeMaxRedirects(options?.maxRedirects);
|
||||
if (policy === requestUrlPolicy && maxRedirects === DEFAULT_MAX_REDIRECTS) {
|
||||
return client;
|
||||
}
|
||||
let clientsForPolicy = scopedClients.get(policy);
|
||||
@@ -39,7 +42,7 @@ function getHttpClientForRequest(options?: SendRequestOptions): HttpClient {
|
||||
}
|
||||
const scopedClient = createHttpClient({
|
||||
userAgent: 'fluxer-api',
|
||||
...(maxRedirects > 0 ? {maxRedirects} : {}),
|
||||
maxRedirects,
|
||||
requestUrlPolicy: policy,
|
||||
});
|
||||
clientsForPolicy.set(maxRedirects, scopedClient);
|
||||
@@ -51,6 +54,12 @@ export async function sendRequest(opts: RequestOptions, options?: SendRequestOpt
|
||||
return requestClient.sendRequest(opts);
|
||||
}
|
||||
|
||||
export function discardResponseBody(stream: ResponseStream, status: number): void {
|
||||
void stream?.cancel().catch(() => {
|
||||
Logger.warn({status}, 'Failed to cancel discarded HTTP response body');
|
||||
});
|
||||
}
|
||||
|
||||
export class ResponseBodyTooLargeError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -87,7 +96,7 @@ function createResponseBodyTooLargeError(
|
||||
actualBytes: number | null,
|
||||
): ResponseBodyTooLargeError {
|
||||
const description = options.description ?? 'Response body';
|
||||
const source = options.url ? ` from ${options.url}` : '';
|
||||
const source = options.url ? ` from ${formatUrlForDiagnostics(options.url)}` : '';
|
||||
const actual = actualBytes == null ? 'unknown size' : `${actualBytes} bytes`;
|
||||
return new ResponseBodyTooLargeError(
|
||||
`${description}${source} exceeds the ${options.maxBytes}-byte limit (${actual})`,
|
||||
@@ -100,6 +109,13 @@ export async function streamToBufferWithLimit(
|
||||
stream: ResponseStream,
|
||||
options: ResponseBodyReadOptions,
|
||||
): Promise<Uint8Array> {
|
||||
if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0) {
|
||||
const error = new RangeError('maxBytes must be a nonnegative safe integer');
|
||||
void stream?.cancel(error).catch(() => {
|
||||
Logger.warn('Failed to cancel HTTP response body after invalid maxBytes');
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (!stream) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function readOptionalIntegerEnv(name: string): number | undefined {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (raw === undefined || raw === '') return undefined;
|
||||
const value = Number(raw);
|
||||
if (!/^-?\d+$/.test(raw) || !Number.isSafeInteger(value)) {
|
||||
throw new Error(`${name} must be a safe integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requireIntegerInRange(name: string, value: number, min: number, max: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
||||
throw new RangeError(`${name} must be an integer between ${min} and ${max}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -44,13 +44,14 @@ export async function getIpAddressReverse(
|
||||
if (cached !== null) return cached === '' ? null : cached;
|
||||
}
|
||||
let result: string | null = null;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
const reversePromise: Promise<Array<string>> = dns.promises.reverse(ip);
|
||||
let hostnames: Array<string>;
|
||||
if (options.timeoutMs !== undefined) {
|
||||
const timeoutMs = options.timeoutMs;
|
||||
const timeoutPromise = new Promise<Array<string>>((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`reverse DNS timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||
timeout = setTimeout(() => reject(new Error(`reverse DNS timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
hostnames = await Promise.race([reversePromise, timeoutPromise]);
|
||||
} else {
|
||||
@@ -59,6 +60,8 @@ export async function getIpAddressReverse(
|
||||
result = hostnames[0] ?? null;
|
||||
} catch {
|
||||
result = null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (cacheService) {
|
||||
await cacheService.set(cacheKey, result ?? '', options.cacheTtlSeconds ?? REVERSE_DNS_CACHE_TTL_SECONDS);
|
||||
|
||||
@@ -12,21 +12,11 @@ export function parseJsonUnknown(text: string): unknown {
|
||||
}
|
||||
|
||||
export function parseJsonRecord(text: string): JsonRecord | null {
|
||||
try {
|
||||
const parsed = parseJsonUnknown(text);
|
||||
return isJsonRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return parseJsonWithGuard(text, isJsonRecord);
|
||||
}
|
||||
|
||||
export function parseJsonArray(text: string): Array<unknown> | null {
|
||||
try {
|
||||
const parsed = parseJsonUnknown(text);
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return parseJsonWithGuard(text, Array.isArray);
|
||||
}
|
||||
|
||||
export function parseJsonWithGuard<T>(text: string, guard: (value: unknown) => value is T): T | null {
|
||||
|
||||
@@ -7,19 +7,17 @@ function isDigit(char: string): boolean {
|
||||
return char >= '0' && char <= '9';
|
||||
}
|
||||
|
||||
function isValidJsonIntegerToken(token: string): boolean {
|
||||
if (!/^-?\d+$/.test(token)) return false;
|
||||
if (token === '0' || token === '-0') return true;
|
||||
function isUnsafeIntegerToken(token: string): boolean {
|
||||
if (!/^-?(?:0|[1-9]\d*)$/.test(token)) return false;
|
||||
const digits = token[0] === '-' ? token.slice(1) : token;
|
||||
return digits.length > 0 && digits[0] !== '0';
|
||||
return digits.length === MAX_SAFE_INTEGER_DECIMAL.length
|
||||
? digits > MAX_SAFE_INTEGER_DECIMAL
|
||||
: digits.length > MAX_SAFE_INTEGER_DECIMAL.length;
|
||||
}
|
||||
|
||||
function isUnsafeIntegerToken(token: string): boolean {
|
||||
const digits = token[0] === '-' ? token.slice(1) : token;
|
||||
if (digits === '0') return false;
|
||||
if (digits.length < MAX_SAFE_INTEGER_DECIMAL.length) return false;
|
||||
if (digits.length > MAX_SAFE_INTEGER_DECIMAL.length) return true;
|
||||
return digits > MAX_SAFE_INTEGER_DECIMAL;
|
||||
function isFollowedByColon(jsonText: string, index: number): boolean {
|
||||
while (index < jsonText.length && ' \t\r\n'.includes(jsonText[index]!)) index++;
|
||||
return jsonText[index] === ':';
|
||||
}
|
||||
|
||||
export function coerceUnsafeIntegersToStrings(jsonText: string): string {
|
||||
@@ -58,7 +56,7 @@ export function coerceUnsafeIntegersToStrings(jsonText: string): string {
|
||||
break;
|
||||
}
|
||||
const token = jsonText.slice(start, i);
|
||||
if (isValidJsonIntegerToken(token) && isUnsafeIntegerToken(token)) {
|
||||
if (isUnsafeIntegerToken(token) && !isFollowedByColon(jsonText, i)) {
|
||||
if (!outputParts) {
|
||||
outputParts = [];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const RANDOM_STRING_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const RANDOM_DIGIT_ALPHABET = '0123456789';
|
||||
|
||||
export function randomNumericCode(length: number): string {
|
||||
const alphabetLength = RANDOM_DIGIT_ALPHABET.length;
|
||||
function randomFromAlphabet(length: number, alphabet: string): string {
|
||||
assert(Number.isSafeInteger(length) && length >= 0, 'Random string length must be a nonnegative safe integer');
|
||||
const alphabetLength = alphabet.length;
|
||||
const rangeSize = 256 - (256 % alphabetLength);
|
||||
const randomBytes = new Uint8Array(length * 2);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
@@ -19,28 +21,15 @@ export function randomNumericCode(length: number): string {
|
||||
}
|
||||
const randomByte = randomBytes[byteIndex++]!;
|
||||
if (randomByte >= rangeSize) continue;
|
||||
result += RANDOM_DIGIT_ALPHABET.charAt(randomByte % alphabetLength);
|
||||
result += alphabet.charAt(randomByte % alphabetLength);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function randomString(length: number) {
|
||||
const alphabetLength = RANDOM_STRING_ALPHABET.length;
|
||||
const rangeSize = 256 - (256 % alphabetLength);
|
||||
const randomBytes = new Uint8Array(length * 2);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
let result = '';
|
||||
let byteIndex = 0;
|
||||
while (result.length < length) {
|
||||
if (byteIndex >= randomBytes.length) {
|
||||
crypto.getRandomValues(randomBytes);
|
||||
byteIndex = 0;
|
||||
}
|
||||
const randomByte = randomBytes[byteIndex++];
|
||||
if (randomByte >= rangeSize) {
|
||||
continue;
|
||||
}
|
||||
result += RANDOM_STRING_ALPHABET.charAt(randomByte % alphabetLength);
|
||||
}
|
||||
return result;
|
||||
export function randomNumericCode(length: number): string {
|
||||
return randomFromAlphabet(length, RANDOM_DIGIT_ALPHABET);
|
||||
}
|
||||
|
||||
export function randomString(length: number): string {
|
||||
return randomFromAlphabet(length, RANDOM_STRING_ALPHABET);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import type {RateLimitResult} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
|
||||
export function getRetryAfterSeconds(result: RateLimitResult): number {
|
||||
return result.retryAfter ?? Math.max(0, Math.ceil((result.resetTime.getTime() - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
export function createRateLimitError(result: RateLimitResult): RateLimitError {
|
||||
return new RateLimitError({
|
||||
retryAfter: getRetryAfterSeconds(result),
|
||||
limit: result.limit,
|
||||
resetTime: result.resetTime,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
|
||||
|
||||
type RefreshTrigger = 'notification' | 'periodic';
|
||||
|
||||
interface RefreshSubscriptionOptions {
|
||||
name: string;
|
||||
channels: ReadonlyArray<string>;
|
||||
refresh: () => Promise<void>;
|
||||
onRefreshError: (error: unknown, trigger: RefreshTrigger) => void;
|
||||
shouldRefresh?: (channel: string, message: string) => boolean;
|
||||
drainInitialNotifications?: boolean;
|
||||
periodicIntervalMs?: () => number | null;
|
||||
closeMode?: 'disconnect' | 'quit';
|
||||
}
|
||||
|
||||
export class RefreshSubscriptionUnavailableError extends Error {
|
||||
constructor(name: string, cause: unknown) {
|
||||
super(`Failed to subscribe ${name}`, {cause});
|
||||
this.name = 'RefreshSubscriptionUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RefreshSubscription {
|
||||
private session: RefreshSubscriptionSession | null = null;
|
||||
|
||||
constructor(private readonly options: RefreshSubscriptionOptions) {}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.session?.isActive ?? false;
|
||||
}
|
||||
|
||||
get isReady(): boolean {
|
||||
return this.session?.isReady ?? false;
|
||||
}
|
||||
|
||||
async start(provider: IKVProvider | null): Promise<void> {
|
||||
const session = (this.session ??= new RefreshSubscriptionSession(this.options, provider));
|
||||
try {
|
||||
await session.start();
|
||||
} catch (error) {
|
||||
try {
|
||||
await session.stop();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], `Failed to initialize and close ${this.options.name}`);
|
||||
} finally {
|
||||
if (this.session === session) this.session = null;
|
||||
}
|
||||
if (error instanceof RefreshSubscriptionUnavailableError) throw error;
|
||||
throw new Error(`Failed to initialize ${this.options.name}`, {cause: error});
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const session = this.session;
|
||||
if (!session) return;
|
||||
try {
|
||||
await session.stop();
|
||||
} finally {
|
||||
if (this.session === session) this.session = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RefreshSubscriptionSession {
|
||||
private readonly subscription: IKVSubscription | null;
|
||||
private phase: 'starting' | 'running' | 'stopping' | 'stopped' = 'starting';
|
||||
private startCompletion: Promise<void> | null = null;
|
||||
private stopCompletion: Promise<void> | null = null;
|
||||
private refreshTask: Promise<void> | null = null;
|
||||
private pendingRefresh: RefreshTrigger | null = null;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private readonly messageHandler = (channel: string, message: string): void => {
|
||||
if (!this.options.channels.includes(channel)) return;
|
||||
if (this.options.shouldRefresh && !this.options.shouldRefresh(channel, message)) return;
|
||||
this.requestRefresh('notification');
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly options: RefreshSubscriptionOptions,
|
||||
provider: IKVProvider | null,
|
||||
) {
|
||||
try {
|
||||
this.subscription = provider?.duplicate() ?? null;
|
||||
} catch (error) {
|
||||
throw new RefreshSubscriptionUnavailableError(options.name, error);
|
||||
}
|
||||
}
|
||||
|
||||
get isReady(): boolean {
|
||||
return this.phase === 'running';
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.phase === 'starting' || this.phase === 'running';
|
||||
}
|
||||
|
||||
start(): Promise<void> {
|
||||
this.assertActive();
|
||||
this.startCompletion ??= Promise.resolve().then(() => this.initialize());
|
||||
return this.startCompletion;
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
this.assertActive();
|
||||
if (this.subscription) {
|
||||
this.subscription.on('message', this.messageHandler);
|
||||
try {
|
||||
await this.subscription.connect();
|
||||
this.assertActive();
|
||||
for (const channel of this.options.channels) {
|
||||
await this.subscription.subscribe(channel);
|
||||
this.assertActive();
|
||||
}
|
||||
} catch (error) {
|
||||
this.assertActive();
|
||||
throw new RefreshSubscriptionUnavailableError(this.options.name, error);
|
||||
}
|
||||
}
|
||||
await this.options.refresh();
|
||||
this.assertActive();
|
||||
while (this.options.drainInitialNotifications && this.pendingRefresh !== null) {
|
||||
this.pendingRefresh = null;
|
||||
await this.options.refresh();
|
||||
this.assertActive();
|
||||
}
|
||||
this.phase = 'running';
|
||||
const intervalMs = this.options.periodicIntervalMs?.();
|
||||
if (intervalMs !== undefined && intervalMs !== null) {
|
||||
this.timer = setInterval(() => this.requestRefresh('periodic'), intervalMs);
|
||||
this.timer.unref();
|
||||
}
|
||||
this.startPendingRefresh();
|
||||
}
|
||||
|
||||
private assertActive(): void {
|
||||
if (!this.isActive) {
|
||||
throw new Error(`${this.options.name} is stopping or stopped`);
|
||||
}
|
||||
}
|
||||
|
||||
private requestRefresh(trigger: RefreshTrigger): void {
|
||||
if (this.phase === 'stopping' || this.phase === 'stopped') return;
|
||||
if (this.pendingRefresh !== 'notification') this.pendingRefresh = trigger;
|
||||
this.startPendingRefresh();
|
||||
}
|
||||
|
||||
private startPendingRefresh(): void {
|
||||
if (!this.isReady || this.refreshTask || this.pendingRefresh === null) return;
|
||||
this.refreshTask = Promise.resolve()
|
||||
.then(() => this.refreshPending())
|
||||
.finally(() => {
|
||||
this.refreshTask = null;
|
||||
this.startPendingRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshPending(): Promise<void> {
|
||||
while (this.isReady && this.pendingRefresh !== null) {
|
||||
const trigger = this.pendingRefresh;
|
||||
this.pendingRefresh = null;
|
||||
try {
|
||||
await this.options.refresh();
|
||||
} catch (error) {
|
||||
this.options.onRefreshError(error, trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
if (this.stopCompletion) return this.stopCompletion;
|
||||
const wasStarting = this.phase === 'starting';
|
||||
this.phase = 'stopping';
|
||||
this.pendingRefresh = null;
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
this.subscription?.off('message', this.messageHandler);
|
||||
this.stopCompletion = this.close(wasStarting);
|
||||
return this.stopCompletion;
|
||||
}
|
||||
|
||||
private async close(wasStarting: boolean): Promise<void> {
|
||||
try {
|
||||
const [closed] = await Promise.allSettled([
|
||||
Promise.resolve().then(() => {
|
||||
if (wasStarting || this.options.closeMode !== 'quit') return this.subscription?.disconnect();
|
||||
return this.subscription?.quit();
|
||||
}),
|
||||
this.startCompletion,
|
||||
this.refreshTask,
|
||||
]);
|
||||
if (closed.status === 'rejected') throw closed.reason;
|
||||
} finally {
|
||||
this.phase = 'stopped';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const VERSION_PATTERN = /^\/v\d+/;
|
||||
const VERSION_PATTERN = /^\/v\d+(?=\/|$)/;
|
||||
|
||||
export function stripApiPrefix(path: string): string {
|
||||
if (path === '/api') {
|
||||
return '/';
|
||||
}
|
||||
if (path.startsWith('/api/')) {
|
||||
const afterApi = path.slice(4);
|
||||
if (VERSION_PATTERN.test(afterApi)) {
|
||||
const versionMatch = afterApi.match(VERSION_PATTERN);
|
||||
if (versionMatch) {
|
||||
const remaining = afterApi.slice(versionMatch[0].length);
|
||||
return remaining === '' ? '/' : remaining;
|
||||
}
|
||||
}
|
||||
return afterApi;
|
||||
}
|
||||
if (VERSION_PATTERN.test(path)) {
|
||||
const versionMatch = path.match(VERSION_PATTERN);
|
||||
if (versionMatch) {
|
||||
const remaining = path.slice(versionMatch[0].length);
|
||||
return remaining === '' ? '/' : remaining;
|
||||
}
|
||||
}
|
||||
return path;
|
||||
const unprefixedPath = path.startsWith('/api/') ? path.slice(4) : path;
|
||||
const versionMatch = unprefixedPath.match(VERSION_PATTERN);
|
||||
return versionMatch ? unprefixedPath.slice(versionMatch[0].length) || '/' : unprefixedPath;
|
||||
}
|
||||
|
||||
export function normalizeRequestPath(path: string): string {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const MAX_DATE_TIMESTAMP = 8640000000000000;
|
||||
|
||||
export function isValidTimestamp(timestamp: unknown): timestamp is number {
|
||||
return typeof timestamp === 'number' && Number.isInteger(timestamp) && Math.abs(timestamp) <= MAX_DATE_TIMESTAMP;
|
||||
}
|
||||
|
||||
function validateTimestamp(timestamp: number, fieldName: string): number {
|
||||
if (!isValidTimestamp(timestamp)) {
|
||||
throw new RangeError(`${fieldName} must be an integer within the valid Date range`);
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
export function getValidTimestamp(date: Date, fieldName: string): number {
|
||||
return validateTimestamp(date.getTime(), fieldName);
|
||||
}
|
||||
|
||||
export function parseStoredTimestamp(value: string | null, fieldName: string): number | null {
|
||||
if (value === null) return null;
|
||||
const digits = value.startsWith('-') ? value.slice(1) : value;
|
||||
if (digits.length === 0 || /\D/.test(digits)) {
|
||||
throw new TypeError(`${fieldName} must be a decimal millisecond timestamp`);
|
||||
}
|
||||
return validateTimestamp(Number(value), fieldName);
|
||||
}
|
||||
@@ -91,16 +91,20 @@ describe('mapWithConcurrency', () => {
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it('propagates a mapper rejection without waiting for another in-flight item', async () => {
|
||||
it('waits for in-flight work before propagating a mapper rejection', async () => {
|
||||
const first = Promise.withResolvers<number>();
|
||||
const second = Promise.withResolvers<number>();
|
||||
const failure = new Error('Mapper failed');
|
||||
const completed: Array<unknown> = [];
|
||||
const secondCompleted = second.promise.then((value) => completed.push(value));
|
||||
const result = mapWithConcurrency([first.promise, second.promise], 2, (promise) => promise);
|
||||
const rejected = expect(result).rejects.toBe(failure);
|
||||
const rejected = result.catch((error) => completed.push(error));
|
||||
first.reject(failure);
|
||||
await rejected;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
second.resolve(2);
|
||||
await second.promise;
|
||||
await Promise.all([rejected, secondCompleted]);
|
||||
expect(completed).toEqual([2, failure]);
|
||||
expect(completed[1]).toBe(failure);
|
||||
});
|
||||
|
||||
it('rejects synchronous mapper failures without starting queued serial work', async () => {
|
||||
|
||||
Reference in new issue
Block a user