@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 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, formatting, text normalization,
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
- `matchesHostnamePattern` supports exact and inclusive `*.` hostname patterns.
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. Callers provide credential
57
- headers and environment or spike policy; this package has no product defaults.
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,4 @@
1
+ /** Ports blocked by https://fetch.spec.whatwg.org/#port-blocking. */
2
+ export declare const FETCH_FORBIDDEN_PORTS: readonly number[];
3
+ export declare function isFetchForbiddenPort(port: number): boolean;
4
+ export declare function isFetchSafePort(port: number): boolean;
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  export declare const SENSITIVE_VALUE = "[Filtered]";
2
+ export { createSpikeWindowTracker, type SpikeWindowTracker } from './spike-window.mts';
2
3
  export interface ScrubOptions {
3
4
  credentialHeaders: readonly string[];
4
5
  }
@@ -1,4 +1,5 @@
1
1
  export const SENSITIVE_VALUE = '[Filtered]';
2
+ export { createSpikeWindowTracker } from './spike-window.mjs';
2
3
  const URL_ATTRIBUTES = [
3
4
  'url',
4
5
  'url.full',
@@ -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.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": {