@vouchington/utils 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/async.d.mts +2 -0
- package/dist/async.mjs +24 -0
- package/dist/bigint-ids.d.mts +2 -0
- package/dist/bigint-ids.mjs +16 -0
- package/dist/collections.d.mts +11 -0
- package/dist/collections.mjs +33 -0
- package/dist/dates.d.mts +17 -0
- package/dist/dates.mjs +54 -0
- package/dist/format.d.mts +8 -0
- package/dist/format.mjs +68 -0
- package/dist/gtin.d.mts +2 -0
- package/dist/gtin.mjs +13 -0
- package/dist/observability.d.mts +18 -0
- package/dist/observability.mjs +120 -0
- package/dist/query-string.d.mts +1 -0
- package/dist/query-string.mjs +11 -0
- package/dist/query.d.mts +9 -0
- package/dist/query.mjs +48 -0
- package/dist/slugs.d.mts +1 -0
- package/dist/slugs.mjs +9 -0
- package/dist/stable-json.d.mts +1 -0
- package/dist/stable-json.mjs +51 -0
- package/dist/strings.d.mts +3 -0
- package/dist/strings.mjs +53 -0
- package/dist/text-metrics.d.mts +2 -0
- package/dist/text-metrics.mjs +6 -0
- package/dist/urls.d.mts +9 -0
- package/dist/urls.mjs +93 -0
- package/dist/validation.d.mts +2 -0
- package/dist/validation.mjs +7 -0
- package/package.json +76 -1
package/README.md
CHANGED
|
@@ -25,6 +25,18 @@ import { serializeCookie } from '@vouchington/utils/cookies'
|
|
|
25
25
|
metadata keys and surface/sensitivity lookups.
|
|
26
26
|
- `cookies`: `serializeCookie(name, value, attributes)` validates cookie grammar and requires the
|
|
27
27
|
caller to supply every policy attribute. `SameSite=None` requires `Secure`.
|
|
28
|
+
- `collections`, `async`, and `stable-json`: collection deduplication/merging, bounded async mapping,
|
|
29
|
+
and deterministic JSON serialization.
|
|
30
|
+
- `dates`, `format`, `strings`, and `text-metrics`: UTC-day, duration, formatting, text normalization,
|
|
31
|
+
title casing, and word/sentence helpers.
|
|
32
|
+
- `gtin`, `bigint-ids`, and `validation`: GTIN predicates, canonical positive PostgreSQL bigint IDs,
|
|
33
|
+
and basic email/UUID predicates.
|
|
34
|
+
- `query` and `query-string`: generic array/boolean/number parsing, bounded integer parsing, and
|
|
35
|
+
query-string serialization. Cursor parsing intentionally belongs to `@vouchington/pagination`.
|
|
36
|
+
- `slugs` and `urls`: ASCII-only slugification and URL/hostname primitives. URL hostname helpers
|
|
37
|
+
return `null` for invalid input; `normalizeAsciiHostname` names its ASCII-only contract directly.
|
|
38
|
+
- `observability`: SDK-free URL, request, event, and span scrubbing. Callers provide credential
|
|
39
|
+
headers and environment or spike policy; this package has no product defaults.
|
|
28
40
|
|
|
29
41
|
```ts
|
|
30
42
|
const money = createMoneyCatalog([{ code: 'credit', minorUnitExponent: 2 }] as const, 6)
|
package/dist/async.d.mts
ADDED
package/dist/async.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export async function mapWithConcurrency(items, concurrency, mapper) {
|
|
2
|
+
const count = Math.min(Math.max(1, Number.isFinite(concurrency) ? Math.floor(concurrency) : items.length), items.length);
|
|
3
|
+
const results = [];
|
|
4
|
+
let next = 0;
|
|
5
|
+
let failure;
|
|
6
|
+
async function worker() {
|
|
7
|
+
while (next < items.length) {
|
|
8
|
+
const index = next++;
|
|
9
|
+
try {
|
|
10
|
+
results[index] = await mapper(items[index]);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
failure ??= { reason: error };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
await Promise.allSettled(Array.from({ length: count }, worker));
|
|
18
|
+
if (failure !== undefined)
|
|
19
|
+
throw failure.reason;
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
export function mapSerially(items, mapper) {
|
|
23
|
+
return mapWithConcurrency(items, 1, mapper);
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const MAX_POSTGRES_BIGINT = BigInt('9223372036854775807');
|
|
2
|
+
export function parsePositivePostgresBigint(value) {
|
|
3
|
+
if (typeof value === 'number')
|
|
4
|
+
return Number.isSafeInteger(value) && value > 0 ? String(value) : null;
|
|
5
|
+
return typeof value === 'string' &&
|
|
6
|
+
/^[1-9]\d*$/.test(value) &&
|
|
7
|
+
value.length <= 19 &&
|
|
8
|
+
BigInt(value) <= MAX_POSTGRES_BIGINT
|
|
9
|
+
? value
|
|
10
|
+
: null;
|
|
11
|
+
}
|
|
12
|
+
export function parsePositivePostgresBigintParam(query, key) {
|
|
13
|
+
return query[key] === undefined
|
|
14
|
+
? undefined
|
|
15
|
+
: (parsePositivePostgresBigint(query[key]) ?? undefined);
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function dedupeBy<T, K>(items: readonly T[], key: (item: T) => K): T[];
|
|
2
|
+
export declare function dedupeByLast<T, K>(items: readonly T[], key: (item: T) => K): T[];
|
|
3
|
+
export declare function dedupeById<T extends {
|
|
4
|
+
id: string;
|
|
5
|
+
}>(items: readonly T[]): T[];
|
|
6
|
+
export declare function mergePageResultsById<T extends {
|
|
7
|
+
id: string;
|
|
8
|
+
}>(pages?: readonly ({
|
|
9
|
+
results?: readonly T[] | null;
|
|
10
|
+
} | null | undefined)[] | null): T[];
|
|
11
|
+
export declare function mergeRecords<T, V>(pages: readonly T[], getRecord: (page: T) => Record<string, V>): Record<string, V>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function dedupeBy(items, key) {
|
|
2
|
+
const seen = new Set();
|
|
3
|
+
return items.filter((item) => {
|
|
4
|
+
const value = key(item);
|
|
5
|
+
if (seen.has(value))
|
|
6
|
+
return false;
|
|
7
|
+
seen.add(value);
|
|
8
|
+
return true;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export function dedupeByLast(items, key) {
|
|
12
|
+
return [...new Map(items.map((item) => [key(item), item])).values()];
|
|
13
|
+
}
|
|
14
|
+
export function dedupeById(items) {
|
|
15
|
+
return dedupeBy(items, (item) => item.id);
|
|
16
|
+
}
|
|
17
|
+
export function mergePageResultsById(pages) {
|
|
18
|
+
return dedupeById((pages ?? []).flatMap((page) => page?.results ?? []));
|
|
19
|
+
}
|
|
20
|
+
export function mergeRecords(pages, getRecord) {
|
|
21
|
+
const merged = {};
|
|
22
|
+
for (const page of pages.toReversed()) {
|
|
23
|
+
for (const [key, value] of Object.entries(getRecord(page))) {
|
|
24
|
+
Object.defineProperty(merged, key, {
|
|
25
|
+
configurable: true,
|
|
26
|
+
enumerable: true,
|
|
27
|
+
value,
|
|
28
|
+
writable: true,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return merged;
|
|
33
|
+
}
|
package/dist/dates.d.mts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare function getCurrentUtcDay(): string;
|
|
2
|
+
export declare function getUtcDayFromDate(date: Date): string;
|
|
3
|
+
export declare function enumerateUtcDaysInclusive(startDay: string, endDay: string): string[];
|
|
4
|
+
export declare function getPreviousUtcDays(count: number, options?: {
|
|
5
|
+
includeToday?: boolean;
|
|
6
|
+
baseDate?: Date;
|
|
7
|
+
}): string[];
|
|
8
|
+
export declare function getDayBounds(day: string): {
|
|
9
|
+
startMs: number;
|
|
10
|
+
endMs: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function parseUtcDay(day: string): {
|
|
13
|
+
year: string;
|
|
14
|
+
month: string;
|
|
15
|
+
day: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function parseDuration(value: unknown): number | null;
|
package/dist/dates.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const DAY = 86_400_000;
|
|
2
|
+
const UTC_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
3
|
+
export function getCurrentUtcDay() {
|
|
4
|
+
return getUtcDayFromDate(new Date());
|
|
5
|
+
}
|
|
6
|
+
export function getUtcDayFromDate(date) {
|
|
7
|
+
return date.toISOString().slice(0, 10);
|
|
8
|
+
}
|
|
9
|
+
export function enumerateUtcDaysInclusive(startDay, endDay) {
|
|
10
|
+
const start = getDayStart(startDay);
|
|
11
|
+
const end = getDayStart(endDay);
|
|
12
|
+
const days = [];
|
|
13
|
+
for (let date = start; date <= end; date = new Date(date.getTime() + DAY))
|
|
14
|
+
days.push(getUtcDayFromDate(date));
|
|
15
|
+
return days;
|
|
16
|
+
}
|
|
17
|
+
export function getPreviousUtcDays(count, options) {
|
|
18
|
+
if (count <= 0)
|
|
19
|
+
return [];
|
|
20
|
+
const offset = options?.includeToday === false ? 1 : 0;
|
|
21
|
+
const date = options?.baseDate ?? new Date();
|
|
22
|
+
return Array.from({ length: count }, (_, index) => getUtcDayFromDate(new Date(date.getTime() - (index + offset) * DAY)));
|
|
23
|
+
}
|
|
24
|
+
export function getDayBounds(day) {
|
|
25
|
+
const startMs = getDayStart(day).getTime();
|
|
26
|
+
return { startMs, endMs: startMs + DAY };
|
|
27
|
+
}
|
|
28
|
+
export function parseUtcDay(day) {
|
|
29
|
+
getDayStart(day);
|
|
30
|
+
const [year, month, dayOfMonth] = day.split('-');
|
|
31
|
+
return { year: year, month: month, day: dayOfMonth };
|
|
32
|
+
}
|
|
33
|
+
export function parseDuration(value) {
|
|
34
|
+
if (typeof value === 'number')
|
|
35
|
+
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
|
|
36
|
+
if (typeof value !== 'string' || !value.trim())
|
|
37
|
+
return null;
|
|
38
|
+
const parts = value.trim().split(':');
|
|
39
|
+
if (!parts.every((part) => /^\d+$/.test(part)) || parts.length > 3)
|
|
40
|
+
return null;
|
|
41
|
+
const values = parts.map(Number);
|
|
42
|
+
const duration = values.length === 1
|
|
43
|
+
? values[0]
|
|
44
|
+
: values.length === 2
|
|
45
|
+
? values[0] * 60 + values[1]
|
|
46
|
+
: values[0] * 3600 + values[1] * 60 + values[2];
|
|
47
|
+
return Number.isFinite(duration) ? duration : null;
|
|
48
|
+
}
|
|
49
|
+
function getDayStart(day) {
|
|
50
|
+
const date = new Date(`${day}T00:00:00.000Z`);
|
|
51
|
+
if (!UTC_DAY.test(day) || Number.isNaN(date.getTime()) || getUtcDayFromDate(date) !== day)
|
|
52
|
+
throw new Error(`Invalid UTC day: ${day}`);
|
|
53
|
+
return date;
|
|
54
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type NumberFormatLocale = string | string[];
|
|
2
|
+
export declare function formatNumber(value: number, locale?: NumberFormatLocale): string;
|
|
3
|
+
export declare function formatCompactNumber(value: number, locale?: NumberFormatLocale): string;
|
|
4
|
+
export declare function formatUtcDate(value: string, locale?: string): string;
|
|
5
|
+
export declare function generateExcerpt(markdown: string, maximumLength?: number): string;
|
|
6
|
+
export declare function formatPercent(value: number): string;
|
|
7
|
+
export declare function formatBytes(value: number): string;
|
|
8
|
+
export declare function formatDuration(seconds: number): string;
|
package/dist/format.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const numbers = new Map();
|
|
2
|
+
const compact = new Map();
|
|
3
|
+
const dates = new Map();
|
|
4
|
+
const key = (locale) => (Array.isArray(locale) ? locale.join('\0') : locale);
|
|
5
|
+
export function formatNumber(value, locale = 'en') {
|
|
6
|
+
const cacheKey = key(locale);
|
|
7
|
+
const formatter = numbers.get(cacheKey) ?? new Intl.NumberFormat(locale, { maximumFractionDigits: 0 });
|
|
8
|
+
numbers.set(cacheKey, formatter);
|
|
9
|
+
return formatter.format(value);
|
|
10
|
+
}
|
|
11
|
+
export function formatCompactNumber(value, locale = 'en') {
|
|
12
|
+
const cacheKey = `${key(locale)}:${value >= 1_000_000_000 ? 1 : 0}`;
|
|
13
|
+
const formatter = compact.get(cacheKey) ??
|
|
14
|
+
new Intl.NumberFormat(locale, {
|
|
15
|
+
notation: 'compact',
|
|
16
|
+
maximumFractionDigits: 1,
|
|
17
|
+
minimumFractionDigits: value >= 1_000_000_000 ? 1 : 0,
|
|
18
|
+
});
|
|
19
|
+
compact.set(cacheKey, formatter);
|
|
20
|
+
return formatter.format(value);
|
|
21
|
+
}
|
|
22
|
+
export function formatUtcDate(value, locale = 'en-US') {
|
|
23
|
+
const formatter = dates.get(locale) ??
|
|
24
|
+
new Intl.DateTimeFormat(locale, {
|
|
25
|
+
month: 'short',
|
|
26
|
+
day: 'numeric',
|
|
27
|
+
year: 'numeric',
|
|
28
|
+
timeZone: 'UTC',
|
|
29
|
+
});
|
|
30
|
+
dates.set(locale, formatter);
|
|
31
|
+
return formatter.format(new Date(value));
|
|
32
|
+
}
|
|
33
|
+
export function generateExcerpt(markdown, maximumLength = 200) {
|
|
34
|
+
const text = markdown
|
|
35
|
+
.replaceAll(/#{1,6}\s+/g, '')
|
|
36
|
+
.replaceAll(/\*\*([^*]+)\*\*/g, '$1')
|
|
37
|
+
.replaceAll(/\*([^*]+)\*/g, '$1')
|
|
38
|
+
.replaceAll(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
39
|
+
.replaceAll(/`([^`]+)`/g, '$1')
|
|
40
|
+
.replaceAll(/\n+/g, ' ')
|
|
41
|
+
.trim();
|
|
42
|
+
if (text.length <= maximumLength)
|
|
43
|
+
return text;
|
|
44
|
+
const truncated = text.slice(0, maximumLength);
|
|
45
|
+
const boundary = truncated.lastIndexOf(' ');
|
|
46
|
+
return `${boundary > 0 ? truncated.slice(0, boundary) : truncated}...`;
|
|
47
|
+
}
|
|
48
|
+
export function formatPercent(value) {
|
|
49
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
50
|
+
}
|
|
51
|
+
export function formatBytes(value) {
|
|
52
|
+
if (value < 1024)
|
|
53
|
+
return `${value} B`;
|
|
54
|
+
if (value < 1024 ** 2)
|
|
55
|
+
return `${(value / 1024).toFixed(1)} KB`;
|
|
56
|
+
if (value < 1024 ** 3)
|
|
57
|
+
return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
|
58
|
+
return `${(value / 1024 ** 3).toFixed(2)} GB`;
|
|
59
|
+
}
|
|
60
|
+
export function formatDuration(seconds) {
|
|
61
|
+
const total = Math.floor(seconds);
|
|
62
|
+
const hours = Math.floor(total / 3600);
|
|
63
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
64
|
+
const remainder = total % 60;
|
|
65
|
+
return hours > 0
|
|
66
|
+
? `${hours}:${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`
|
|
67
|
+
: `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
|
|
68
|
+
}
|
package/dist/gtin.d.mts
ADDED
package/dist/gtin.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const FORMAT = /^\d{8}$|^\d{12}$|^\d{13}$|^\d{14}$/;
|
|
2
|
+
export function isValidGtinFormat(value) {
|
|
3
|
+
return FORMAT.test(value);
|
|
4
|
+
}
|
|
5
|
+
export function isValidGtinCheckDigit(value) {
|
|
6
|
+
if (!isValidGtinFormat(value))
|
|
7
|
+
return false;
|
|
8
|
+
const digits = value.padStart(14, '0').split('').map(Number);
|
|
9
|
+
const total = digits
|
|
10
|
+
.slice(0, 13)
|
|
11
|
+
.reduce((sum, digit, index) => sum + digit * (index % 2 === 0 ? 3 : 1), 0);
|
|
12
|
+
return (10 - (total % 10)) % 10 === digits[13];
|
|
13
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const SENSITIVE_VALUE = "[Filtered]";
|
|
2
|
+
export interface ScrubOptions {
|
|
3
|
+
credentialHeaders: readonly string[];
|
|
4
|
+
}
|
|
5
|
+
export interface ObservableEvent {
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
breadcrumbs?: Array<{
|
|
8
|
+
data?: Record<string, unknown>;
|
|
9
|
+
}>;
|
|
10
|
+
request?: object;
|
|
11
|
+
}
|
|
12
|
+
export declare function stripUrlQueryAndFragment(value: string): string;
|
|
13
|
+
export declare function scrubHeaders(headers: Record<string, unknown>, credentialHeaders: readonly string[]): Record<string, unknown>;
|
|
14
|
+
export declare function scrubSpanAttributes<T extends Record<string, unknown>>(data: T, options: ScrubOptions): T;
|
|
15
|
+
export declare function scrubEvent<T extends ObservableEvent>(event: T, options: ScrubOptions): T;
|
|
16
|
+
export declare function composeBeforeSend<T extends ObservableEvent, THint>(beforeSend: ((event: T, hint: THint) => T | null | Promise<T | null>) | undefined, options: ScrubOptions): (event: T, hint: THint) => T | null | Promise<T | null>;
|
|
17
|
+
export declare function isAllowedEnvironment(value: string | undefined, allowed: readonly string[]): boolean;
|
|
18
|
+
export declare function shouldReportSpike(count: number, threshold: number): boolean;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export const SENSITIVE_VALUE = '[Filtered]';
|
|
2
|
+
const URL_ATTRIBUTES = [
|
|
3
|
+
'url',
|
|
4
|
+
'url.full',
|
|
5
|
+
'http.url',
|
|
6
|
+
'http.target',
|
|
7
|
+
'http.request.header.referer',
|
|
8
|
+
'http.request.header.referrer',
|
|
9
|
+
];
|
|
10
|
+
const URL_COMPONENT_ATTRIBUTES = ['url.query', 'http.query', 'url.fragment', 'http.fragment'];
|
|
11
|
+
export function stripUrlQueryAndFragment(value) {
|
|
12
|
+
const match = /[?#]/.exec(value);
|
|
13
|
+
return match ? value.slice(0, match.index) : value;
|
|
14
|
+
}
|
|
15
|
+
export function scrubHeaders(headers, credentialHeaders) {
|
|
16
|
+
const credentials = new Set(credentialHeaders.map((name) => name.toLowerCase()));
|
|
17
|
+
return Object.fromEntries(Object.entries(headers).map(([name, value]) => [
|
|
18
|
+
name,
|
|
19
|
+
credentials.has(name.toLowerCase())
|
|
20
|
+
? SENSITIVE_VALUE
|
|
21
|
+
: isUrlHeader(name)
|
|
22
|
+
? scrubUrlHeaderValue(value)
|
|
23
|
+
: value,
|
|
24
|
+
]));
|
|
25
|
+
}
|
|
26
|
+
export function scrubSpanAttributes(data, options) {
|
|
27
|
+
const credentialKeys = new Set(options.credentialHeaders.flatMap((name) => {
|
|
28
|
+
const lower = name.toLowerCase();
|
|
29
|
+
return [`http.request.header.${lower}`, `http.request.header.${lower.replaceAll('-', '_')}`];
|
|
30
|
+
}));
|
|
31
|
+
const result = Object.entries(data).flatMap(([key, value]) => {
|
|
32
|
+
if (URL_COMPONENT_ATTRIBUTES.includes(key))
|
|
33
|
+
return [];
|
|
34
|
+
if (credentialKeys.has(key) || key.startsWith('http.request.header.cookie.'))
|
|
35
|
+
return [[key, SENSITIVE_VALUE]];
|
|
36
|
+
return [
|
|
37
|
+
[
|
|
38
|
+
key,
|
|
39
|
+
URL_ATTRIBUTES.includes(key) && typeof value === 'string'
|
|
40
|
+
? stripUrlQueryAndFragment(value)
|
|
41
|
+
: value,
|
|
42
|
+
],
|
|
43
|
+
];
|
|
44
|
+
});
|
|
45
|
+
const changed = result.length !== Object.keys(data).length || result.some(([key, value]) => data[key] !== value);
|
|
46
|
+
return (changed ? Object.fromEntries(result) : data);
|
|
47
|
+
}
|
|
48
|
+
export function scrubEvent(event, options) {
|
|
49
|
+
const request = scrubRequest(event.request, options);
|
|
50
|
+
const breadcrumbs = event.breadcrumbs?.map((breadcrumb) => breadcrumb.data
|
|
51
|
+
? { ...breadcrumb, data: scrubSpanAttributes(breadcrumb.data, options) }
|
|
52
|
+
: breadcrumb);
|
|
53
|
+
const changed = request !== event.request ||
|
|
54
|
+
breadcrumbs?.some((breadcrumb, index) => breadcrumb !== event.breadcrumbs?.[index]);
|
|
55
|
+
return changed
|
|
56
|
+
? {
|
|
57
|
+
...event,
|
|
58
|
+
...(request === event.request ? {} : { request }),
|
|
59
|
+
...(breadcrumbs === event.breadcrumbs ? {} : { breadcrumbs }),
|
|
60
|
+
}
|
|
61
|
+
: event;
|
|
62
|
+
}
|
|
63
|
+
export function composeBeforeSend(beforeSend, options) {
|
|
64
|
+
return (event, hint) => {
|
|
65
|
+
const result = beforeSend ? beforeSend(event, hint) : event;
|
|
66
|
+
if (isPromiseLike(result))
|
|
67
|
+
return Promise.resolve(result).then((next) => next === null ? null : scrubEvent(next, options));
|
|
68
|
+
return result === null ? null : scrubEvent(result, options);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function isPromiseLike(value) {
|
|
72
|
+
return value !== null && typeof value.then === 'function';
|
|
73
|
+
}
|
|
74
|
+
export function isAllowedEnvironment(value, allowed) {
|
|
75
|
+
return value !== undefined && allowed.includes(value);
|
|
76
|
+
}
|
|
77
|
+
export function shouldReportSpike(count, threshold) {
|
|
78
|
+
return Number.isFinite(count) && Number.isFinite(threshold) && count >= threshold;
|
|
79
|
+
}
|
|
80
|
+
function scrubRequest(request, options) {
|
|
81
|
+
if (!request)
|
|
82
|
+
return request;
|
|
83
|
+
const fields = request;
|
|
84
|
+
const headers = isRecord(fields.headers)
|
|
85
|
+
? scrubHeaders(fields.headers, options.credentialHeaders)
|
|
86
|
+
: fields.headers;
|
|
87
|
+
const cookies = isRecord(fields.cookies)
|
|
88
|
+
? Object.fromEntries(Object.keys(fields.cookies).map((name) => [name, SENSITIVE_VALUE]))
|
|
89
|
+
: fields.cookies;
|
|
90
|
+
const result = Object.entries(fields).flatMap(([key, value]) => key === 'query_string'
|
|
91
|
+
? []
|
|
92
|
+
: [
|
|
93
|
+
[
|
|
94
|
+
key,
|
|
95
|
+
key === 'url' && typeof value === 'string'
|
|
96
|
+
? stripUrlQueryAndFragment(value)
|
|
97
|
+
: key === 'headers'
|
|
98
|
+
? headers
|
|
99
|
+
: key === 'cookies'
|
|
100
|
+
? cookies
|
|
101
|
+
: value,
|
|
102
|
+
],
|
|
103
|
+
]);
|
|
104
|
+
const changed = result.length !== Object.keys(fields).length ||
|
|
105
|
+
result.some(([key, value]) => fields[key] !== value);
|
|
106
|
+
return (changed ? Object.fromEntries(result) : request);
|
|
107
|
+
}
|
|
108
|
+
function isUrlHeader(name) {
|
|
109
|
+
return ['referer', 'referrer'].includes(name.toLowerCase());
|
|
110
|
+
}
|
|
111
|
+
function scrubUrlHeaderValue(value) {
|
|
112
|
+
if (typeof value === 'string')
|
|
113
|
+
return stripUrlQueryAndFragment(value);
|
|
114
|
+
return Array.isArray(value)
|
|
115
|
+
? value.map((item) => (typeof item === 'string' ? stripUrlQueryAndFragment(item) : item))
|
|
116
|
+
: value;
|
|
117
|
+
}
|
|
118
|
+
function isRecord(value) {
|
|
119
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function buildQueryString(params: Record<string, unknown>): string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function buildQueryString(params) {
|
|
2
|
+
const query = new URLSearchParams();
|
|
3
|
+
for (const [key, value] of Object.entries(params)) {
|
|
4
|
+
if (value == null)
|
|
5
|
+
continue;
|
|
6
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
7
|
+
query.append(key, String(item));
|
|
8
|
+
}
|
|
9
|
+
const serialized = query.toString();
|
|
10
|
+
return serialized ? `?${serialized}` : '';
|
|
11
|
+
}
|
package/dist/query.d.mts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function parseStringArray(value: unknown): string[];
|
|
2
|
+
export declare function parseBooleanish(value: unknown): boolean;
|
|
3
|
+
export declare function parseNumberParam(query: Record<string, unknown>, key: string): number | undefined;
|
|
4
|
+
export declare function parseNumberParams(query: Record<string, unknown>, keys: readonly string[]): Record<string, number>;
|
|
5
|
+
export declare function parseBoundedInteger(value: unknown, bounds: {
|
|
6
|
+
default: number;
|
|
7
|
+
minimum: number;
|
|
8
|
+
maximum: number;
|
|
9
|
+
}): number;
|
package/dist/query.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function parseStringArray(value) {
|
|
2
|
+
const values = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
|
|
3
|
+
return values.flatMap((item) => {
|
|
4
|
+
const normalized = String(item).trim();
|
|
5
|
+
return normalized ? [normalized] : [];
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
export function parseBooleanish(value) {
|
|
9
|
+
if (typeof value === 'boolean')
|
|
10
|
+
return value;
|
|
11
|
+
if (typeof value === 'number')
|
|
12
|
+
return value !== 0;
|
|
13
|
+
return typeof value === 'string' && ['1', 'true'].includes(value.toLowerCase());
|
|
14
|
+
}
|
|
15
|
+
export function parseNumberParam(query, key) {
|
|
16
|
+
const input = query[key];
|
|
17
|
+
if (input === undefined || input === null || (typeof input === 'string' && input.trim() === '')) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const value = Number(input);
|
|
21
|
+
return Number.isNaN(value) ? undefined : value;
|
|
22
|
+
}
|
|
23
|
+
export function parseNumberParams(query, keys) {
|
|
24
|
+
return Object.fromEntries(keys.flatMap((key) => {
|
|
25
|
+
const value = parseNumberParam(query, key);
|
|
26
|
+
return value === undefined ? [] : [[key, value]];
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
export function parseBoundedInteger(value, bounds) {
|
|
30
|
+
if (!Number.isSafeInteger(bounds.default) ||
|
|
31
|
+
!Number.isSafeInteger(bounds.minimum) ||
|
|
32
|
+
!Number.isSafeInteger(bounds.maximum) ||
|
|
33
|
+
bounds.minimum > bounds.maximum ||
|
|
34
|
+
bounds.default < bounds.minimum ||
|
|
35
|
+
bounds.default > bounds.maximum)
|
|
36
|
+
throw new RangeError('Invalid integer bounds');
|
|
37
|
+
if (value === undefined || value === null || value === '')
|
|
38
|
+
return bounds.default;
|
|
39
|
+
const text = typeof value === 'number' ? String(value) : typeof value === 'string' ? value : '';
|
|
40
|
+
if (!/^-?\d+$/.test(text))
|
|
41
|
+
throw new TypeError('Expected a canonical integer');
|
|
42
|
+
const parsed = Number(text);
|
|
43
|
+
if (!Number.isSafeInteger(parsed))
|
|
44
|
+
throw new TypeError('Expected a safe integer');
|
|
45
|
+
if (parsed < bounds.minimum || parsed > bounds.maximum)
|
|
46
|
+
throw new RangeError(`Expected an integer between ${bounds.minimum} and ${bounds.maximum}`);
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
package/dist/slugs.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function slugifyAscii(value: string): string;
|
package/dist/slugs.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stableJsonStringify(value: unknown): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function stableJsonStringify(value) {
|
|
2
|
+
return `${format(sort(value))}\n`;
|
|
3
|
+
}
|
|
4
|
+
function sort(value) {
|
|
5
|
+
if (hasToJson(value))
|
|
6
|
+
return sort(value.toJSON());
|
|
7
|
+
if (Array.isArray(value)) {
|
|
8
|
+
return Array.from(value, (item) => item === undefined || typeof item === 'function' || typeof item === 'symbol'
|
|
9
|
+
? null
|
|
10
|
+
: sort(item));
|
|
11
|
+
}
|
|
12
|
+
if (value === undefined || typeof value === 'function' || typeof value === 'symbol')
|
|
13
|
+
return null;
|
|
14
|
+
if (value == null || typeof value !== 'object')
|
|
15
|
+
return value;
|
|
16
|
+
return Object.fromEntries(Object.entries(value)
|
|
17
|
+
.filter(([, nested]) => nested !== undefined && typeof nested !== 'function' && typeof nested !== 'symbol')
|
|
18
|
+
.toSorted(([left], [right]) => (left < right ? -1 : 1))
|
|
19
|
+
.map(([key, nested]) => [key, sort(nested)]));
|
|
20
|
+
}
|
|
21
|
+
function hasToJson(value) {
|
|
22
|
+
return (value != null &&
|
|
23
|
+
typeof value === 'object' &&
|
|
24
|
+
typeof value.toJSON === 'function');
|
|
25
|
+
}
|
|
26
|
+
function format(value, indent = 0, prefix = 0) {
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
if (value.length === 0)
|
|
29
|
+
return '[]';
|
|
30
|
+
const canInline = value.every((item) => item == null || ['boolean', 'number', 'string'].includes(typeof item));
|
|
31
|
+
if (canInline) {
|
|
32
|
+
const inline = `[${value.map((item) => JSON.stringify(item)).join(', ')}]`;
|
|
33
|
+
if (inline.length <= 100 - indent - prefix)
|
|
34
|
+
return inline;
|
|
35
|
+
}
|
|
36
|
+
const child = ' '.repeat(indent + 2);
|
|
37
|
+
return `[\n${value.map((item) => `${child}${format(item, indent + 2)}`).join(',\n')}\n${' '.repeat(indent)}]`;
|
|
38
|
+
}
|
|
39
|
+
if (value == null || typeof value !== 'object')
|
|
40
|
+
return JSON.stringify(value);
|
|
41
|
+
const entries = Object.entries(value);
|
|
42
|
+
if (!entries.length)
|
|
43
|
+
return '{}';
|
|
44
|
+
const child = ' '.repeat(indent + 2);
|
|
45
|
+
return `{\n${entries
|
|
46
|
+
.map(([key, nested]) => {
|
|
47
|
+
const serialized = JSON.stringify(key);
|
|
48
|
+
return `${child}${serialized}: ${format(nested, indent + 2, serialized.length + 2)}`;
|
|
49
|
+
})
|
|
50
|
+
.join(',\n')}\n${' '.repeat(indent)}}`;
|
|
51
|
+
}
|
package/dist/strings.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const LOWERCASE = new Set([
|
|
2
|
+
'a',
|
|
3
|
+
'an',
|
|
4
|
+
'the',
|
|
5
|
+
'and',
|
|
6
|
+
'but',
|
|
7
|
+
'or',
|
|
8
|
+
'nor',
|
|
9
|
+
'for',
|
|
10
|
+
'yet',
|
|
11
|
+
'so',
|
|
12
|
+
'at',
|
|
13
|
+
'by',
|
|
14
|
+
'in',
|
|
15
|
+
'of',
|
|
16
|
+
'on',
|
|
17
|
+
'to',
|
|
18
|
+
'up',
|
|
19
|
+
'as',
|
|
20
|
+
'is',
|
|
21
|
+
'if',
|
|
22
|
+
'it',
|
|
23
|
+
'via',
|
|
24
|
+
'vs',
|
|
25
|
+
'with',
|
|
26
|
+
'from',
|
|
27
|
+
'into',
|
|
28
|
+
'over',
|
|
29
|
+
'upon',
|
|
30
|
+
'than',
|
|
31
|
+
'that',
|
|
32
|
+
'then',
|
|
33
|
+
]);
|
|
34
|
+
export function toTitleCase(value) {
|
|
35
|
+
const words = value.split(/\s+/);
|
|
36
|
+
return words
|
|
37
|
+
.map((word, index) => {
|
|
38
|
+
if (word.length <= 4 && word === word.toUpperCase())
|
|
39
|
+
return word;
|
|
40
|
+
const lower = word.toLowerCase();
|
|
41
|
+
if (index > 0 && index < words.length - 1 && LOWERCASE.has(lower))
|
|
42
|
+
return lower;
|
|
43
|
+
return `${word.charAt(0).toUpperCase()}${lower.slice(1)}`;
|
|
44
|
+
})
|
|
45
|
+
.join(' ');
|
|
46
|
+
}
|
|
47
|
+
export function normalizeKey(value) {
|
|
48
|
+
return value.trim().toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
export function stripControlCharacters(value) {
|
|
51
|
+
// oxlint-disable-next-line no-control-regex -- this explicitly filters unsupported C0 controls and DEL.
|
|
52
|
+
return value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
53
|
+
}
|
package/dist/urls.d.mts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function extractUrlScheme(value: string): string | null;
|
|
2
|
+
export declare function sanitizeLinkUrl(value: string | null | undefined): string | null;
|
|
3
|
+
export declare function sanitizeImageUrl(value: string | null | undefined): string | null;
|
|
4
|
+
export declare function isExternalHttpUrl(value: string | null | undefined): boolean;
|
|
5
|
+
export declare function isAsciiHostname(value: string): boolean;
|
|
6
|
+
export declare function normalizeAsciiHostname(value: string): string | null;
|
|
7
|
+
export declare function extractUrlHostname(value: string): string | null;
|
|
8
|
+
export declare function getFirstPathSegment(pathname: string): string | null;
|
|
9
|
+
export declare function matchesPathnamePattern(pathname: string, pattern: string): boolean;
|
package/dist/urls.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const SCHEME = /^([a-z][a-z0-9+.-]*):/i;
|
|
2
|
+
const LINK_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
|
|
3
|
+
const IMAGE_SCHEMES = new Set(['http', 'https']);
|
|
4
|
+
export function extractUrlScheme(value) {
|
|
5
|
+
return value.match(SCHEME)?.[1]?.toLowerCase() ?? null;
|
|
6
|
+
}
|
|
7
|
+
function sanitize(value, allowed) {
|
|
8
|
+
const normalized = typeof value === 'string' ? value.trim() : '';
|
|
9
|
+
// oxlint-disable-next-line no-control-regex -- reject URL-parser-normalized C0/DEL bypasses.
|
|
10
|
+
if (!normalized || /[\u0000-\u001F\u007F]/.test(normalized))
|
|
11
|
+
return null;
|
|
12
|
+
const scheme = extractUrlScheme(normalized);
|
|
13
|
+
if (!scheme && normalized.startsWith('//'))
|
|
14
|
+
return null;
|
|
15
|
+
return !scheme || allowed.has(scheme) ? normalized : null;
|
|
16
|
+
}
|
|
17
|
+
export function sanitizeLinkUrl(value) {
|
|
18
|
+
return sanitize(value, LINK_SCHEMES);
|
|
19
|
+
}
|
|
20
|
+
export function sanitizeImageUrl(value) {
|
|
21
|
+
return sanitize(value, IMAGE_SCHEMES);
|
|
22
|
+
}
|
|
23
|
+
export function isExternalHttpUrl(value) {
|
|
24
|
+
const sanitized = sanitizeLinkUrl(value);
|
|
25
|
+
const scheme = sanitized ? extractUrlScheme(sanitized) : null;
|
|
26
|
+
return scheme === 'http' || scheme === 'https';
|
|
27
|
+
}
|
|
28
|
+
export function isAsciiHostname(value) {
|
|
29
|
+
return (Boolean(value) &&
|
|
30
|
+
value.length <= 255 &&
|
|
31
|
+
value.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)));
|
|
32
|
+
}
|
|
33
|
+
export function normalizeAsciiHostname(value) {
|
|
34
|
+
const input = value
|
|
35
|
+
.trim()
|
|
36
|
+
.replace(/^(https?):\/+|^(https?):/i, (_match, first, second) => `${first ?? second}://`);
|
|
37
|
+
if (!input)
|
|
38
|
+
return null;
|
|
39
|
+
try {
|
|
40
|
+
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(input) ? input : `https://${input}`;
|
|
41
|
+
const authority = candidate.match(/^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i)?.[1];
|
|
42
|
+
// oxlint-disable-next-line no-control-regex -- validate the authority before URL punycode conversion.
|
|
43
|
+
if (!authority || /[^\x00-\x7F]/.test(authority))
|
|
44
|
+
return null;
|
|
45
|
+
const url = new URL(candidate);
|
|
46
|
+
const hostname = url.hostname.toLowerCase().replace(/\.$/, '');
|
|
47
|
+
return !url.username && !url.password && hostname.length <= 255 && isAsciiHostname(hostname)
|
|
48
|
+
? hostname
|
|
49
|
+
: null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function extractUrlHostname(value) {
|
|
56
|
+
try {
|
|
57
|
+
return new URL(value).hostname || null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function getFirstPathSegment(pathname) {
|
|
64
|
+
if (pathname === '/')
|
|
65
|
+
return null;
|
|
66
|
+
const index = pathname.indexOf('/', 1);
|
|
67
|
+
return index < 0 ? '/' : pathname.slice(0, index);
|
|
68
|
+
}
|
|
69
|
+
export function matchesPathnamePattern(pathname, pattern) {
|
|
70
|
+
let pathIndex = 0;
|
|
71
|
+
let patternIndex = 0;
|
|
72
|
+
let wildcardPathIndex = -1;
|
|
73
|
+
let wildcardPatternIndex = -1;
|
|
74
|
+
while (pathIndex < pathname.length) {
|
|
75
|
+
if (pattern[patternIndex] === '%') {
|
|
76
|
+
wildcardPatternIndex = patternIndex++;
|
|
77
|
+
wildcardPathIndex = pathIndex;
|
|
78
|
+
}
|
|
79
|
+
else if (pattern[patternIndex] === '_' || pattern[patternIndex] === pathname[pathIndex]) {
|
|
80
|
+
pathIndex++;
|
|
81
|
+
patternIndex++;
|
|
82
|
+
}
|
|
83
|
+
else if (wildcardPatternIndex >= 0) {
|
|
84
|
+
patternIndex = wildcardPatternIndex + 1;
|
|
85
|
+
pathIndex = ++wildcardPathIndex;
|
|
86
|
+
}
|
|
87
|
+
else
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
while (pattern[patternIndex] === '%')
|
|
91
|
+
patternIndex++;
|
|
92
|
+
return patternIndex === pattern.length;
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vouchington/utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Dependency-free runtime utilities for Vouchington-style applications.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/utils#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -54,6 +54,81 @@
|
|
|
54
54
|
"types": "./dist/cookies.d.mts",
|
|
55
55
|
"import": "./dist/cookies.mjs",
|
|
56
56
|
"default": "./dist/cookies.mjs"
|
|
57
|
+
},
|
|
58
|
+
"./collections": {
|
|
59
|
+
"types": "./dist/collections.d.mts",
|
|
60
|
+
"import": "./dist/collections.mjs",
|
|
61
|
+
"default": "./dist/collections.mjs"
|
|
62
|
+
},
|
|
63
|
+
"./dates": {
|
|
64
|
+
"types": "./dist/dates.d.mts",
|
|
65
|
+
"import": "./dist/dates.mjs",
|
|
66
|
+
"default": "./dist/dates.mjs"
|
|
67
|
+
},
|
|
68
|
+
"./format": {
|
|
69
|
+
"types": "./dist/format.d.mts",
|
|
70
|
+
"import": "./dist/format.mjs",
|
|
71
|
+
"default": "./dist/format.mjs"
|
|
72
|
+
},
|
|
73
|
+
"./gtin": {
|
|
74
|
+
"types": "./dist/gtin.d.mts",
|
|
75
|
+
"import": "./dist/gtin.mjs",
|
|
76
|
+
"default": "./dist/gtin.mjs"
|
|
77
|
+
},
|
|
78
|
+
"./bigint-ids": {
|
|
79
|
+
"types": "./dist/bigint-ids.d.mts",
|
|
80
|
+
"import": "./dist/bigint-ids.mjs",
|
|
81
|
+
"default": "./dist/bigint-ids.mjs"
|
|
82
|
+
},
|
|
83
|
+
"./query": {
|
|
84
|
+
"types": "./dist/query.d.mts",
|
|
85
|
+
"import": "./dist/query.mjs",
|
|
86
|
+
"default": "./dist/query.mjs"
|
|
87
|
+
},
|
|
88
|
+
"./query-string": {
|
|
89
|
+
"types": "./dist/query-string.d.mts",
|
|
90
|
+
"import": "./dist/query-string.mjs",
|
|
91
|
+
"default": "./dist/query-string.mjs"
|
|
92
|
+
},
|
|
93
|
+
"./slugs": {
|
|
94
|
+
"types": "./dist/slugs.d.mts",
|
|
95
|
+
"import": "./dist/slugs.mjs",
|
|
96
|
+
"default": "./dist/slugs.mjs"
|
|
97
|
+
},
|
|
98
|
+
"./strings": {
|
|
99
|
+
"types": "./dist/strings.d.mts",
|
|
100
|
+
"import": "./dist/strings.mjs",
|
|
101
|
+
"default": "./dist/strings.mjs"
|
|
102
|
+
},
|
|
103
|
+
"./text-metrics": {
|
|
104
|
+
"types": "./dist/text-metrics.d.mts",
|
|
105
|
+
"import": "./dist/text-metrics.mjs",
|
|
106
|
+
"default": "./dist/text-metrics.mjs"
|
|
107
|
+
},
|
|
108
|
+
"./urls": {
|
|
109
|
+
"types": "./dist/urls.d.mts",
|
|
110
|
+
"import": "./dist/urls.mjs",
|
|
111
|
+
"default": "./dist/urls.mjs"
|
|
112
|
+
},
|
|
113
|
+
"./validation": {
|
|
114
|
+
"types": "./dist/validation.d.mts",
|
|
115
|
+
"import": "./dist/validation.mjs",
|
|
116
|
+
"default": "./dist/validation.mjs"
|
|
117
|
+
},
|
|
118
|
+
"./async": {
|
|
119
|
+
"types": "./dist/async.d.mts",
|
|
120
|
+
"import": "./dist/async.mjs",
|
|
121
|
+
"default": "./dist/async.mjs"
|
|
122
|
+
},
|
|
123
|
+
"./stable-json": {
|
|
124
|
+
"types": "./dist/stable-json.d.mts",
|
|
125
|
+
"import": "./dist/stable-json.mjs",
|
|
126
|
+
"default": "./dist/stable-json.mjs"
|
|
127
|
+
},
|
|
128
|
+
"./observability": {
|
|
129
|
+
"types": "./dist/observability.d.mts",
|
|
130
|
+
"import": "./dist/observability.mjs",
|
|
131
|
+
"default": "./dist/observability.mjs"
|
|
57
132
|
}
|
|
58
133
|
},
|
|
59
134
|
"publishConfig": {
|