@vouchington/utils 0.3.0 → 0.4.1

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,23 @@ 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.
47
+ - `http-body`: bounded Fetch response-body reads with abort propagation and typed missing/oversize
48
+ errors.
49
+ - `http-link-pagination`: fail-closed RFC 8288 next-link parsing with same-origin URL and
50
+ unambiguous cursor extraction.
43
51
  - `language-tags`: caller-configured locale normalization and strict `Accept-Language` parsing and
44
52
  best matching. HTTP ranges reject malformed syntax and quality parameters; explicit `q=0` ranges
45
53
  exclude a candidate when they tie for that candidate's most-specific matching range, so `en;q=0`
@@ -53,8 +61,9 @@ import { createMessageTranslator } from '@vouchington/utils/message-catalog'
53
61
  descriptors. Catalog segments cannot contain dots because dots delimit nested keys. Plural values
54
62
  and configured number parameters must be finite numbers; callers own catalog content, locale
55
63
  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.
64
+ - `observability`: SDK-free URL, request, event, and span scrubbing plus a bounded spike-window
65
+ tracker. Callers provide credential headers and environment or spike policy; this package has no
66
+ product defaults.
58
67
 
59
68
  ```ts
60
69
  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
+ }
@@ -0,0 +1,18 @@
1
+ export interface ReadResponseBodyOptions {
2
+ readonly response: Response;
3
+ readonly url: string;
4
+ readonly maxSizeBytes: number;
5
+ readonly signal?: AbortSignal;
6
+ }
7
+ export declare class MissingResponseBodyError extends Error {
8
+ readonly url: string;
9
+ constructor(url: string);
10
+ }
11
+ export declare class ResponseBodyTooLargeError extends Error {
12
+ readonly url: string;
13
+ readonly sizeBytes: number;
14
+ readonly maxSizeBytes: number;
15
+ constructor(url: string, sizeBytes: number, maxSizeBytes: number);
16
+ }
17
+ export declare function readResponseBodyAsBuffer({ response, url, maxSizeBytes, signal, }: ReadResponseBodyOptions): Promise<Buffer>;
18
+ export declare function readResponseBody(options: ReadResponseBodyOptions): Promise<string>;
@@ -0,0 +1,86 @@
1
+ export class MissingResponseBodyError extends Error {
2
+ url;
3
+ constructor(url) {
4
+ super(`HTTP response from ${url} has no body`);
5
+ this.name = 'MissingResponseBodyError';
6
+ this.url = url;
7
+ }
8
+ }
9
+ export class ResponseBodyTooLargeError extends Error {
10
+ url;
11
+ sizeBytes;
12
+ maxSizeBytes;
13
+ constructor(url, sizeBytes, maxSizeBytes) {
14
+ super(`HTTP response from ${url} exceeded ${maxSizeBytes} bytes (read ${sizeBytes})`);
15
+ this.name = 'ResponseBodyTooLargeError';
16
+ this.url = url;
17
+ this.sizeBytes = sizeBytes;
18
+ this.maxSizeBytes = maxSizeBytes;
19
+ }
20
+ }
21
+ function abortReason(signal) {
22
+ if (signal.reason instanceof Error)
23
+ return signal.reason;
24
+ if (signal.reason !== undefined)
25
+ return new Error(String(signal.reason));
26
+ return new Error('HTTP response body read aborted');
27
+ }
28
+ function validateMaxSize(maxSizeBytes) {
29
+ if (!Number.isSafeInteger(maxSizeBytes) || maxSizeBytes < 0) {
30
+ throw new RangeError('maxSizeBytes must be a non-negative safe integer');
31
+ }
32
+ }
33
+ export async function readResponseBodyAsBuffer({ response, url, maxSizeBytes, signal, }) {
34
+ validateMaxSize(maxSizeBytes);
35
+ const reader = response.body?.getReader();
36
+ if (!reader)
37
+ throw new MissingResponseBodyError(url);
38
+ let abortListener;
39
+ let abortPromise;
40
+ if (signal) {
41
+ abortPromise = new Promise((_resolve, reject) => {
42
+ abortListener = () => {
43
+ const reason = abortReason(signal);
44
+ void reader.cancel(reason).catch(() => undefined);
45
+ reject(reason);
46
+ };
47
+ if (signal.aborted)
48
+ abortListener();
49
+ else
50
+ signal.addEventListener('abort', abortListener, { once: true });
51
+ });
52
+ void abortPromise.catch(() => undefined);
53
+ }
54
+ const chunks = [];
55
+ let totalSize = 0;
56
+ try {
57
+ for (;;) {
58
+ const result = await (abortPromise
59
+ ? Promise.race([reader.read(), abortPromise])
60
+ : reader.read());
61
+ if (signal?.aborted)
62
+ throw abortReason(signal);
63
+ if (result.done)
64
+ return Buffer.concat(chunks, totalSize);
65
+ totalSize += result.value.byteLength;
66
+ if (totalSize > maxSizeBytes) {
67
+ const error = new ResponseBodyTooLargeError(url, totalSize, maxSizeBytes);
68
+ await reader.cancel(error).catch(() => undefined);
69
+ throw error;
70
+ }
71
+ chunks.push(result.value);
72
+ }
73
+ }
74
+ catch (error) {
75
+ await reader.cancel(error).catch(() => undefined);
76
+ throw error;
77
+ }
78
+ finally {
79
+ if (signal && abortListener)
80
+ signal.removeEventListener('abort', abortListener);
81
+ reader.releaseLock();
82
+ }
83
+ }
84
+ export async function readResponseBody(options) {
85
+ return (await readResponseBodyAsBuffer(options)).toString('utf8');
86
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Resolve the `rel=next` target in an RFC 8288 Link header without permitting a
3
+ * response to redirect an authenticated pagination client to another origin.
4
+ *
5
+ * A malformed or unsafe Link header is treated as having no next page. Callers
6
+ * can therefore stop safely without having to distinguish an exhausted list
7
+ * from a provider that returned an unusable continuation.
8
+ */
9
+ export declare function nextPageUrlFromLinkHeader(linkHeader: string | null | undefined, requestUrl: string | URL): URL | null;
10
+ /** Return an unambiguous next-page cursor from a safe Link header target. */
11
+ export declare function nextPageCursorFromLinkHeader(linkHeader: string | null | undefined, requestUrl: string | URL, cursorParameter?: string): string | null;
12
+ /** Validate a pagination request URL before it is used as a Link resolution base. */
13
+ export declare function validatePaginationRequestUrl(requestUrl: string | URL): URL;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Resolve the `rel=next` target in an RFC 8288 Link header without permitting a
3
+ * response to redirect an authenticated pagination client to another origin.
4
+ *
5
+ * A malformed or unsafe Link header is treated as having no next page. Callers
6
+ * can therefore stop safely without having to distinguish an exhausted list
7
+ * from a provider that returned an unusable continuation.
8
+ */
9
+ export function nextPageUrlFromLinkHeader(linkHeader, requestUrl) {
10
+ const baseUrl = validatePaginationRequestUrl(requestUrl);
11
+ if (linkHeader == null)
12
+ return null;
13
+ const links = parseLinkHeader(linkHeader);
14
+ if (!links)
15
+ return null;
16
+ const nextLinks = links.filter((link) => link.relations.includes('next'));
17
+ if (nextLinks.length !== 1)
18
+ return null;
19
+ let nextUrl;
20
+ try {
21
+ nextUrl = new URL(nextLinks[0].target, baseUrl);
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ if (nextUrl.origin !== baseUrl.origin ||
27
+ nextUrl.username !== '' ||
28
+ nextUrl.password !== '' ||
29
+ nextUrl.hash !== '') {
30
+ return null;
31
+ }
32
+ return nextUrl;
33
+ }
34
+ /** Return an unambiguous next-page cursor from a safe Link header target. */
35
+ export function nextPageCursorFromLinkHeader(linkHeader, requestUrl, cursorParameter = 'page') {
36
+ const nextUrl = nextPageUrlFromLinkHeader(linkHeader, requestUrl);
37
+ if (!nextUrl)
38
+ return null;
39
+ const values = nextUrl.searchParams.getAll(cursorParameter);
40
+ return values.length === 1 && values[0] !== '' ? values[0] : null;
41
+ }
42
+ /** Validate a pagination request URL before it is used as a Link resolution base. */
43
+ export function validatePaginationRequestUrl(requestUrl) {
44
+ let url;
45
+ try {
46
+ url = new URL(requestUrl);
47
+ }
48
+ catch {
49
+ throw new Error('pagination request URL must be an absolute HTTP(S) URL without credentials');
50
+ }
51
+ if (!['http:', 'https:'].includes(url.protocol) ||
52
+ url.username !== '' ||
53
+ url.password !== '' ||
54
+ url.hash !== '') {
55
+ throw new Error('pagination request URL must be an absolute HTTP(S) URL without credentials');
56
+ }
57
+ return url;
58
+ }
59
+ function parseLinkHeader(header) {
60
+ const links = [];
61
+ let index = 0;
62
+ while (index < header.length) {
63
+ index = skipWhitespace(header, index);
64
+ if (header[index] !== '<')
65
+ return null;
66
+ const targetEnd = header.indexOf('>', index + 1);
67
+ if (targetEnd === -1)
68
+ return null;
69
+ const target = header.slice(index + 1, targetEnd);
70
+ if (target === '')
71
+ return null;
72
+ index = targetEnd + 1;
73
+ const relations = [];
74
+ while (true) {
75
+ index = skipWhitespace(header, index);
76
+ if (index === header.length || header[index] === ',')
77
+ break;
78
+ if (header[index] !== ';')
79
+ return null;
80
+ index = skipWhitespace(header, index + 1);
81
+ const name = readToken(header, index);
82
+ if (!name)
83
+ return null;
84
+ index = name.end;
85
+ index = skipWhitespace(header, index);
86
+ if (header[index] !== '=')
87
+ return null;
88
+ index = skipWhitespace(header, index + 1);
89
+ const value = readParameterValue(header, index);
90
+ if (!value)
91
+ return null;
92
+ index = value.end;
93
+ if (name.value.toLowerCase() === 'rel') {
94
+ relations.push(...value.value.toLowerCase().split(/\s+/).filter(Boolean));
95
+ }
96
+ }
97
+ links.push({ target, relations });
98
+ if (index === header.length)
99
+ return links;
100
+ index += 1;
101
+ if (index === header.length)
102
+ return null;
103
+ }
104
+ return null;
105
+ }
106
+ function skipWhitespace(value, index) {
107
+ while (value[index] === ' ' || value[index] === '\t')
108
+ index += 1;
109
+ return index;
110
+ }
111
+ function readToken(value, index) {
112
+ const start = index;
113
+ while (index < value.length && isTokenCharacter(value[index]))
114
+ index += 1;
115
+ return index === start ? null : { value: value.slice(start, index), end: index };
116
+ }
117
+ function isTokenCharacter(value) {
118
+ return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]$/.test(value);
119
+ }
120
+ function readParameterValue(value, index) {
121
+ if (value[index] !== '"')
122
+ return readToken(value, index);
123
+ let parsed = '';
124
+ index += 1;
125
+ while (index < value.length) {
126
+ const character = value[index];
127
+ if (character === '"')
128
+ return { value: parsed, end: index + 1 };
129
+ if (character === '\\') {
130
+ const escaped = value[index + 1];
131
+ if (!escaped)
132
+ return null;
133
+ parsed += escaped;
134
+ index += 2;
135
+ continue;
136
+ }
137
+ parsed += character;
138
+ index += 1;
139
+ }
140
+ return null;
141
+ }
@@ -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.1",
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,26 @@
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"
157
+ },
158
+ "./http-body": {
159
+ "types": "./dist/http-body.d.mts",
160
+ "import": "./dist/http-body.mjs",
161
+ "default": "./dist/http-body.mjs"
162
+ },
163
+ "./http-link-pagination": {
164
+ "types": "./dist/http-link-pagination.d.mts",
165
+ "import": "./dist/http-link-pagination.mjs",
166
+ "default": "./dist/http-link-pagination.mjs"
147
167
  }
148
168
  },
149
169
  "publishConfig": {