@vouchington/utils 0.3.0 → 0.4.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 +11 -6
- package/dist/fetch-ports.d.mts +4 -0
- package/dist/fetch-ports.mjs +15 -0
- package/dist/format.d.mts +2 -0
- package/dist/format.mjs +8 -0
- package/dist/hashtags.d.mts +15 -0
- package/dist/hashtags.mjs +44 -0
- package/dist/observability.d.mts +1 -0
- package/dist/observability.mjs +1 -0
- package/dist/spike-window.d.mts +6 -0
- package/dist/spike-window.mjs +41 -0
- package/dist/urls.d.mts +4 -0
- package/dist/urls.mjs +22 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -31,15 +31,19 @@ import { createMessageTranslator } from '@vouchington/utils/message-catalog'
|
|
|
31
31
|
and deterministic JSON serialization.
|
|
32
32
|
- `feature-flags`: base64 JSON feature-flag cookie parsing and serialization with caller-injected
|
|
33
33
|
codecs, cookie names, and size policy.
|
|
34
|
-
- `dates`, `format`, `strings`, and `text-metrics`: UTC-day, duration,
|
|
35
|
-
title casing, and word/sentence helpers.
|
|
34
|
+
- `dates`, `format`, `strings`, and `text-metrics`: UTC-day, duration, weighted-average and display
|
|
35
|
+
formatting, text normalization, title casing, and word/sentence helpers.
|
|
36
36
|
- `gtin`, `bigint-ids`, and `validation`: GTIN predicates, canonical positive PostgreSQL bigint IDs,
|
|
37
37
|
and basic email/UUID predicates.
|
|
38
38
|
- `query` and `query-string`: generic array/boolean/number parsing, bounded integer parsing, and
|
|
39
39
|
query-string serialization. Cursor parsing intentionally belongs to `@vouchington/pagination`.
|
|
40
40
|
- `slugs` and `urls`: ASCII-only slugification and URL/hostname primitives. URL hostname helpers
|
|
41
|
-
return `null` for invalid input; `normalizeAsciiHostname` names its ASCII-only contract directly
|
|
42
|
-
`
|
|
41
|
+
return `null` for invalid input; `normalizeAsciiHostname` names its ASCII-only contract directly,
|
|
42
|
+
while `normalizeHostname` also supports WHATWG IDN conversion. `matchesHostnamePattern` supports
|
|
43
|
+
exact and inclusive `*.` hostname patterns.
|
|
44
|
+
- `hashtags`: caller-configured authored/key length and separator policy for canonical ASCII hashtag
|
|
45
|
+
keys. The package does not choose application limits or accepted input separators.
|
|
46
|
+
- `fetch-ports`: the Fetch standard forbidden-port list and membership predicates.
|
|
43
47
|
- `language-tags`: caller-configured locale normalization and strict `Accept-Language` parsing and
|
|
44
48
|
best matching. HTTP ranges reject malformed syntax and quality parameters; explicit `q=0` ranges
|
|
45
49
|
exclude a candidate when they tie for that candidate's most-specific matching range, so `en;q=0`
|
|
@@ -53,8 +57,9 @@ import { createMessageTranslator } from '@vouchington/utils/message-catalog'
|
|
|
53
57
|
descriptors. Catalog segments cannot contain dots because dots delimit nested keys. Plural values
|
|
54
58
|
and configured number parameters must be finite numbers; callers own catalog content, locale
|
|
55
59
|
choice, and number formatting.
|
|
56
|
-
- `observability`: SDK-free URL, request, event, and span scrubbing
|
|
57
|
-
headers and environment or spike policy; this package has no
|
|
60
|
+
- `observability`: SDK-free URL, request, event, and span scrubbing plus a bounded spike-window
|
|
61
|
+
tracker. Callers provide credential headers and environment or spike policy; this package has no
|
|
62
|
+
product defaults.
|
|
58
63
|
|
|
59
64
|
```ts
|
|
60
65
|
const money = createMoneyCatalog([{ code: 'credit', minorUnitExponent: 2 }] as const, 6)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Ports blocked by https://fetch.spec.whatwg.org/#port-blocking. */
|
|
2
|
+
export const FETCH_FORBIDDEN_PORTS = Object.freeze([
|
|
3
|
+
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102,
|
|
4
|
+
103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465,
|
|
5
|
+
512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993,
|
|
6
|
+
995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668,
|
|
7
|
+
6669, 6679, 6697, 10080,
|
|
8
|
+
]);
|
|
9
|
+
const FETCH_FORBIDDEN_PORT_SET = new Set(FETCH_FORBIDDEN_PORTS);
|
|
10
|
+
export function isFetchForbiddenPort(port) {
|
|
11
|
+
return FETCH_FORBIDDEN_PORT_SET.has(port);
|
|
12
|
+
}
|
|
13
|
+
export function isFetchSafePort(port) {
|
|
14
|
+
return Number.isInteger(port) && port >= 0 && port <= 65_535 && !isFetchForbiddenPort(port);
|
|
15
|
+
}
|
package/dist/format.d.mts
CHANGED
|
@@ -6,3 +6,5 @@ export declare function generateExcerpt(markdown: string, maximumLength?: number
|
|
|
6
6
|
export declare function formatPercent(value: number): string;
|
|
7
7
|
export declare function formatBytes(value: number): string;
|
|
8
8
|
export declare function formatDuration(seconds: number): string;
|
|
9
|
+
/** Calculates the weighted mean of numeric record keys, or null when the total weight is zero. */
|
|
10
|
+
export declare function calculateWeightedAverage(weights: Readonly<Record<string, number>>): number | null;
|
package/dist/format.mjs
CHANGED
|
@@ -66,3 +66,11 @@ export function formatDuration(seconds) {
|
|
|
66
66
|
? `${hours}:${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`
|
|
67
67
|
: `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
|
|
68
68
|
}
|
|
69
|
+
/** Calculates the weighted mean of numeric record keys, or null when the total weight is zero. */
|
|
70
|
+
export function calculateWeightedAverage(weights) {
|
|
71
|
+
const total = Object.values(weights).reduce((sum, weight) => sum + weight, 0);
|
|
72
|
+
if (total === 0)
|
|
73
|
+
return null;
|
|
74
|
+
const weightedTotal = Object.entries(weights).reduce((sum, [value, weight]) => sum + Number(value) * weight, 0);
|
|
75
|
+
return weightedTotal / total;
|
|
76
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface HashtagNormalizerOptions {
|
|
2
|
+
maximumAuthoredLength: number;
|
|
3
|
+
maximumKeyLength: number;
|
|
4
|
+
separators: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
export interface NormalizedHashtag {
|
|
7
|
+
authored: string;
|
|
8
|
+
key: string;
|
|
9
|
+
}
|
|
10
|
+
export interface HashtagNormalizer {
|
|
11
|
+
normalize(input: string): NormalizedHashtag | null;
|
|
12
|
+
normalizeQuery(input: string): string;
|
|
13
|
+
}
|
|
14
|
+
/** Creates an ASCII hashtag normalizer whose length and separator policy is caller-owned. */
|
|
15
|
+
export declare function createHashtagNormalizer(options: HashtagNormalizerOptions): HashtagNormalizer;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const HASHTAG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2
|
+
/** Creates an ASCII hashtag normalizer whose length and separator policy is caller-owned. */
|
|
3
|
+
export function createHashtagNormalizer(options) {
|
|
4
|
+
assertLength(options.maximumAuthoredLength, 'maximumAuthoredLength');
|
|
5
|
+
assertLength(options.maximumKeyLength, 'maximumKeyLength');
|
|
6
|
+
const maximumAuthoredLength = options.maximumAuthoredLength;
|
|
7
|
+
const maximumKeyLength = options.maximumKeyLength;
|
|
8
|
+
const separatorPattern = createSeparatorPattern(options.separators);
|
|
9
|
+
function normalizeQuery(input) {
|
|
10
|
+
const withoutPrefix = input.trim().replace(/^#/, '');
|
|
11
|
+
const separated = separatorPattern
|
|
12
|
+
? withoutPrefix.replace(separatorPattern, '-')
|
|
13
|
+
: withoutPrefix;
|
|
14
|
+
return separated.replace(/-+/g, '-').toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
normalize(input) {
|
|
18
|
+
const authored = input.trim();
|
|
19
|
+
const key = normalizeQuery(authored);
|
|
20
|
+
if (authored.length === 0 ||
|
|
21
|
+
authored.length > maximumAuthoredLength ||
|
|
22
|
+
key.length === 0 ||
|
|
23
|
+
key.length > maximumKeyLength ||
|
|
24
|
+
!HASHTAG_PATTERN.test(key)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return { authored, key };
|
|
28
|
+
},
|
|
29
|
+
normalizeQuery,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function assertLength(value, name) {
|
|
33
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
34
|
+
throw new RangeError(`${name} must be a non-negative safe integer`);
|
|
35
|
+
}
|
|
36
|
+
function createSeparatorPattern(separators) {
|
|
37
|
+
const tokens = [...new Set(separators.filter((separator) => separator.length > 0))].sort((left, right) => right.length - left.length);
|
|
38
|
+
if (tokens.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
return new RegExp(`(?:${tokens.map(escapeRegularExpression).join('|')})+`, 'g');
|
|
41
|
+
}
|
|
42
|
+
function escapeRegularExpression(value) {
|
|
43
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
44
|
+
}
|
package/dist/observability.d.mts
CHANGED
package/dist/observability.mjs
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface SpikeWindowTracker {
|
|
2
|
+
readonly size: number;
|
|
3
|
+
recordAndCheck(fingerprint: string, timestamp: number): boolean;
|
|
4
|
+
}
|
|
5
|
+
/** Tracks per-fingerprint occurrence counts while evicting expired windows. */
|
|
6
|
+
export declare function createSpikeWindowTracker(limit: number, windowMs: number): SpikeWindowTracker;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Tracks per-fingerprint occurrence counts while evicting expired windows. */
|
|
2
|
+
export function createSpikeWindowTracker(limit, windowMs) {
|
|
3
|
+
if (!Number.isSafeInteger(limit) || limit < 0)
|
|
4
|
+
throw new RangeError('limit must be a non-negative safe integer');
|
|
5
|
+
if (!Number.isFinite(windowMs) || windowMs <= 0)
|
|
6
|
+
throw new RangeError('windowMs must be positive and finite');
|
|
7
|
+
const windows = new Map();
|
|
8
|
+
let lastSweepAt = -Infinity;
|
|
9
|
+
let lastTimestamp = -Infinity;
|
|
10
|
+
function sweepExpiredWindows(timestamp) {
|
|
11
|
+
if (timestamp - lastSweepAt < windowMs)
|
|
12
|
+
return;
|
|
13
|
+
lastSweepAt = timestamp;
|
|
14
|
+
for (const [fingerprint, window] of windows) {
|
|
15
|
+
if (timestamp - window.windowStart >= windowMs)
|
|
16
|
+
windows.delete(fingerprint);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
get size() {
|
|
21
|
+
return windows.size;
|
|
22
|
+
},
|
|
23
|
+
recordAndCheck(fingerprint, timestamp) {
|
|
24
|
+
if (!Number.isFinite(timestamp))
|
|
25
|
+
throw new RangeError('timestamp must be finite');
|
|
26
|
+
if (timestamp < lastTimestamp) {
|
|
27
|
+
windows.clear();
|
|
28
|
+
lastSweepAt = timestamp;
|
|
29
|
+
}
|
|
30
|
+
lastTimestamp = timestamp;
|
|
31
|
+
sweepExpiredWindows(timestamp);
|
|
32
|
+
const existing = windows.get(fingerprint);
|
|
33
|
+
const window = existing && timestamp - existing.windowStart < windowMs
|
|
34
|
+
? existing
|
|
35
|
+
: { count: 0, windowStart: timestamp };
|
|
36
|
+
window.count += 1;
|
|
37
|
+
windows.set(fingerprint, window);
|
|
38
|
+
return window.count <= limit;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
package/dist/urls.d.mts
CHANGED
|
@@ -4,6 +4,10 @@ export declare function sanitizeImageUrl(value: string | null | undefined): stri
|
|
|
4
4
|
export declare function isExternalHttpUrl(value: string | null | undefined): boolean;
|
|
5
5
|
export declare function isAsciiHostname(value: string): boolean;
|
|
6
6
|
export declare function normalizeAsciiHostname(value: string): string | null;
|
|
7
|
+
/** Normalizes ASCII or IDN hostnames, using WHATWG URL punycode conversion for Unicode input. */
|
|
8
|
+
export declare function normalizeHostname(value: string): string | null;
|
|
9
|
+
/** Removes one leading lowercase `www.` and trailing dots for presentation. */
|
|
10
|
+
export declare function formatHostnameForDisplay(hostname: string): string;
|
|
7
11
|
export declare function extractUrlHostname(value: string): string | null;
|
|
8
12
|
export declare function getFirstPathSegment(pathname: string): string | null;
|
|
9
13
|
export declare function matchesPathnamePattern(pathname: string, pattern: string): boolean;
|
package/dist/urls.mjs
CHANGED
|
@@ -52,6 +52,28 @@ export function normalizeAsciiHostname(value) {
|
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
/** Normalizes ASCII or IDN hostnames, using WHATWG URL punycode conversion for Unicode input. */
|
|
56
|
+
export function normalizeHostname(value) {
|
|
57
|
+
const ascii = normalizeAsciiHostname(value);
|
|
58
|
+
// oxlint-disable-next-line no-control-regex -- detect input requiring WHATWG punycode conversion.
|
|
59
|
+
if (ascii !== null || !/[^\x00-\x7F]/.test(value))
|
|
60
|
+
return ascii;
|
|
61
|
+
const input = value
|
|
62
|
+
.trim()
|
|
63
|
+
.replace(/^(https?):\/+|^(https?):/i, (_match, first, second) => `${first ?? second}://`);
|
|
64
|
+
try {
|
|
65
|
+
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(input) ? input : `https://${input}`;
|
|
66
|
+
const url = new URL(candidate);
|
|
67
|
+
return url.username || url.password ? null : normalizeAsciiHostname(url.hostname);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Removes one leading lowercase `www.` and trailing dots for presentation. */
|
|
74
|
+
export function formatHostnameForDisplay(hostname) {
|
|
75
|
+
return hostname.replace(/^www\./, '').replace(/\.+$/, '');
|
|
76
|
+
}
|
|
55
77
|
export function extractUrlHostname(value) {
|
|
56
78
|
try {
|
|
57
79
|
return new URL(value).hostname || null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vouchington/utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": {
|
|
@@ -144,6 +144,16 @@
|
|
|
144
144
|
"types": "./dist/observability.d.mts",
|
|
145
145
|
"import": "./dist/observability.mjs",
|
|
146
146
|
"default": "./dist/observability.mjs"
|
|
147
|
+
},
|
|
148
|
+
"./hashtags": {
|
|
149
|
+
"types": "./dist/hashtags.d.mts",
|
|
150
|
+
"import": "./dist/hashtags.mjs",
|
|
151
|
+
"default": "./dist/hashtags.mjs"
|
|
152
|
+
},
|
|
153
|
+
"./fetch-ports": {
|
|
154
|
+
"types": "./dist/fetch-ports.d.mts",
|
|
155
|
+
"import": "./dist/fetch-ports.mjs",
|
|
156
|
+
"default": "./dist/fetch-ports.mjs"
|
|
147
157
|
}
|
|
148
158
|
},
|
|
149
159
|
"publishConfig": {
|