@vouchington/utils 0.4.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 +4 -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/package.json +11 -1
package/README.md
CHANGED
|
@@ -44,6 +44,10 @@ import { createMessageTranslator } from '@vouchington/utils/message-catalog'
|
|
|
44
44
|
- `hashtags`: caller-configured authored/key length and separator policy for canonical ASCII hashtag
|
|
45
45
|
keys. The package does not choose application limits or accepted input separators.
|
|
46
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.
|
|
47
51
|
- `language-tags`: caller-configured locale normalization and strict `Accept-Language` parsing and
|
|
48
52
|
best matching. HTTP ranges reject malformed syntax and quality parameters; explicit `q=0` ranges
|
|
49
53
|
exclude a candidate when they tie for that candidate's most-specific matching range, so `en;q=0`
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vouchington/utils",
|
|
3
|
-
"version": "0.4.
|
|
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": {
|
|
@@ -154,6 +154,16 @@
|
|
|
154
154
|
"types": "./dist/fetch-ports.d.mts",
|
|
155
155
|
"import": "./dist/fetch-ports.mjs",
|
|
156
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"
|
|
157
167
|
}
|
|
158
168
|
},
|
|
159
169
|
"publishConfig": {
|