@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 +15 -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/http-body.d.mts +18 -0
- package/dist/http-body.mjs +86 -0
- package/dist/http-link-pagination.d.mts +13 -0
- package/dist/http-link-pagination.mjs +141 -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 +21 -1
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,
|
|
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.
|
|
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
|
|
57
|
-
headers and environment or spike policy; this package has no
|
|
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,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
|
+
}
|
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.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": {
|