@fedify/vocab-runtime 2.4.0-dev.2200 → 2.4.0-dev.2212
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/deno.json +1 -1
- package/dist/mod.cjs +145 -1
- package/dist/mod.d.cts +45 -1
- package/dist/mod.d.ts +45 -1
- package/dist/mod.js +145 -2
- package/dist/tests/{body-C7VkG5Rz.cjs → body-BG5Ol-IR.cjs} +1 -1
- package/dist/tests/{body-DxnRZZTZ.mjs → body-DbGNUM07.mjs} +1 -1
- package/dist/tests/body.test.cjs +1 -1
- package/dist/tests/body.test.mjs +1 -1
- package/dist/tests/decimal.test.cjs +2 -1
- package/dist/tests/decimal.test.mjs +2 -1
- package/dist/tests/{docloader-DzUk-dX-.cjs → docloader-BC9UAgDx.cjs} +2 -2
- package/dist/tests/{docloader-lhhaq3_J.mjs → docloader-C-c5dtFJ.mjs} +2 -2
- package/dist/tests/docloader.test.cjs +2 -2
- package/dist/tests/docloader.test.mjs +2 -2
- package/dist/tests/portable-media-Cn4uVXaI.cjs +153 -0
- package/dist/tests/portable-media-nj7yXhru.mjs +148 -0
- package/dist/tests/portable-media.test.cjs +222 -0
- package/dist/tests/portable-media.test.d.cts +1 -0
- package/dist/tests/portable-media.test.d.mts +1 -0
- package/dist/tests/portable-media.test.mjs +223 -0
- package/dist/tests/{request-AYfyUo0m.mjs → request-CfuNbRJC.mjs} +1 -1
- package/dist/tests/{request-D5KwMCcr.cjs → request-jic6JEwl.cjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/package.json +1 -1
- package/src/mod.ts +5 -0
- package/src/portable-media.test.ts +293 -0
- package/src/portable-media.ts +264 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { MAX_BODY_SIZE, readBoundedBytes } from "./body.ts";
|
|
2
|
+
import {
|
|
3
|
+
parseDigestMultibase,
|
|
4
|
+
parseHashlink,
|
|
5
|
+
verifyDigestMultibase,
|
|
6
|
+
} from "./digest.ts";
|
|
7
|
+
import { getUserAgent, type GetUserAgentOptions } from "./request.ts";
|
|
8
|
+
import { parseGatewayUrl, validatePublicUrl } from "./url.ts";
|
|
9
|
+
|
|
10
|
+
/** A link or document with an external resource and its integrity digest. */
|
|
11
|
+
export interface PortableMedia {
|
|
12
|
+
readonly href?: URL | null;
|
|
13
|
+
readonly url?: URL | {
|
|
14
|
+
readonly href: URL | null;
|
|
15
|
+
readonly digestMultibase?: string | null;
|
|
16
|
+
} | null;
|
|
17
|
+
readonly digestMultibase?: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Options for {@link fetchPortableMedia}. */
|
|
21
|
+
export interface FetchPortableMediaOptions {
|
|
22
|
+
/** Ordered FEP-ef61 gateway origins used for `hl:` resources. */
|
|
23
|
+
readonly gateways?: Iterable<string | URL>;
|
|
24
|
+
/** Expected digest when the first argument is a URL or string. */
|
|
25
|
+
readonly digestMultibase?: string;
|
|
26
|
+
/** Maximum decoded response size in bytes. Defaults to 16 MiB. */
|
|
27
|
+
readonly maxBytes?: number;
|
|
28
|
+
/** Whether private network addresses may be fetched. Defaults to `false`. */
|
|
29
|
+
readonly allowPrivateAddress?: boolean;
|
|
30
|
+
/** The HTTP `User-Agent` value or its components. */
|
|
31
|
+
readonly userAgent?: string | GetUserAgentOptions;
|
|
32
|
+
/** Cancels the entire retrieval and all gateway attempts. */
|
|
33
|
+
readonly signal?: AbortSignal;
|
|
34
|
+
/** Overall timeout in milliseconds. Defaults to 10 seconds; `null` disables it. */
|
|
35
|
+
readonly timeout?: number | null;
|
|
36
|
+
/** Timeout per gateway in milliseconds. Defaults to 3 seconds. */
|
|
37
|
+
readonly gatewayTimeout?: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveTimeout(
|
|
41
|
+
value: number | null | undefined,
|
|
42
|
+
fallback: number,
|
|
43
|
+
): number | null {
|
|
44
|
+
if (value === null) return null;
|
|
45
|
+
const result = value ?? fallback;
|
|
46
|
+
if (!Number.isFinite(result) || result <= 0 || result > 2_147_483_647) {
|
|
47
|
+
throw new RangeError(
|
|
48
|
+
"A media timeout must be a positive finite number of milliseconds.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return Math.ceil(result);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function withTimeout<T>(
|
|
55
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
56
|
+
parent: AbortSignal | undefined,
|
|
57
|
+
timeout: number | null,
|
|
58
|
+
): Promise<T> {
|
|
59
|
+
parent?.throwIfAborted();
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const onParentAbort = () => controller.abort(parent?.reason);
|
|
62
|
+
parent?.addEventListener("abort", onParentAbort, { once: true });
|
|
63
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
if (timeout != null) {
|
|
65
|
+
timer = setTimeout(() =>
|
|
66
|
+
controller.abort(
|
|
67
|
+
new DOMException(
|
|
68
|
+
`Media retrieval timed out after ${timeout} ms.`,
|
|
69
|
+
"TimeoutError",
|
|
70
|
+
),
|
|
71
|
+
), timeout);
|
|
72
|
+
}
|
|
73
|
+
let onAbort: (() => void) | undefined;
|
|
74
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
75
|
+
onAbort = () => reject(controller.signal.reason);
|
|
76
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
return await Promise.race([operation(controller.signal), aborted]);
|
|
80
|
+
} finally {
|
|
81
|
+
if (timer != null) clearTimeout(timer);
|
|
82
|
+
parent?.removeEventListener("abort", onParentAbort);
|
|
83
|
+
if (onAbort != null) {
|
|
84
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function mediaTarget(media: string | URL | PortableMedia): {
|
|
90
|
+
url: string;
|
|
91
|
+
digestMultibase: string | null | undefined;
|
|
92
|
+
} {
|
|
93
|
+
if (typeof media === "string" || media instanceof URL) {
|
|
94
|
+
return { url: media.toString(), digestMultibase: undefined };
|
|
95
|
+
}
|
|
96
|
+
const target = media.href ?? media.url;
|
|
97
|
+
const url = target instanceof URL
|
|
98
|
+
? target.href
|
|
99
|
+
: target != null && typeof target === "object" && "href" in target
|
|
100
|
+
? target.href?.href
|
|
101
|
+
: undefined;
|
|
102
|
+
if (url == null) throw new TypeError("Portable media has no URL or href.");
|
|
103
|
+
const linkDigest = target != null && !(target instanceof URL) &&
|
|
104
|
+
typeof target === "object" && "digestMultibase" in target
|
|
105
|
+
? target.digestMultibase
|
|
106
|
+
: undefined;
|
|
107
|
+
if (media.digestMultibase != null && linkDigest != null) {
|
|
108
|
+
const outer = parseDigestMultibase(media.digestMultibase).digest;
|
|
109
|
+
const inner = parseDigestMultibase(linkDigest).digest;
|
|
110
|
+
if (outer.some((byte, i) => byte !== inner[i])) {
|
|
111
|
+
throw new TypeError("Media and linked resource digests disagree.");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { url, digestMultibase: media.digestMultibase ?? linkDigest };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function fetchVerified(
|
|
118
|
+
url: URL,
|
|
119
|
+
digestMultibase: string,
|
|
120
|
+
options: FetchPortableMediaOptions,
|
|
121
|
+
signal: AbortSignal,
|
|
122
|
+
maxBytes: number,
|
|
123
|
+
): Promise<Response> {
|
|
124
|
+
const visited = new Set<string>();
|
|
125
|
+
for (let redirects = 0; redirects <= 20; redirects++) {
|
|
126
|
+
signal.throwIfAborted();
|
|
127
|
+
if (url.username !== "" || url.password !== "") {
|
|
128
|
+
throw new TypeError("Media URLs must not contain credentials.");
|
|
129
|
+
}
|
|
130
|
+
if (!options.allowPrivateAddress) await validatePublicUrl(url.href);
|
|
131
|
+
else if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
132
|
+
throw new TypeError("Media URLs must use HTTP(S).");
|
|
133
|
+
}
|
|
134
|
+
if (visited.has(url.href)) throw new TypeError("Media redirect loop.");
|
|
135
|
+
visited.add(url.href);
|
|
136
|
+
const response = await fetch(url, {
|
|
137
|
+
method: "GET",
|
|
138
|
+
headers: {
|
|
139
|
+
Accept: "*/*",
|
|
140
|
+
"User-Agent": typeof options.userAgent === "string"
|
|
141
|
+
? options.userAgent
|
|
142
|
+
: getUserAgent(options.userAgent),
|
|
143
|
+
},
|
|
144
|
+
redirect: "manual",
|
|
145
|
+
signal,
|
|
146
|
+
});
|
|
147
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
148
|
+
const location = response.headers.get("Location");
|
|
149
|
+
void response.body?.cancel().catch(() => {});
|
|
150
|
+
if (location == null || redirects === 20) {
|
|
151
|
+
throw new TypeError("Invalid or excessive media redirects.");
|
|
152
|
+
}
|
|
153
|
+
url = new URL(location, url);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (response.status !== 200) {
|
|
157
|
+
void response.body?.cancel().catch(() => {});
|
|
158
|
+
throw new Error(`Media request failed with HTTP ${response.status}.`);
|
|
159
|
+
}
|
|
160
|
+
const bytes = await readBoundedBytes(response, maxBytes, url);
|
|
161
|
+
signal.throwIfAborted();
|
|
162
|
+
if (!await verifyDigestMultibase(bytes, digestMultibase)) {
|
|
163
|
+
throw new Error("Portable media digest does not match.");
|
|
164
|
+
}
|
|
165
|
+
const headers = new Headers();
|
|
166
|
+
const contentType = response.headers.get("Content-Type");
|
|
167
|
+
if (contentType != null) headers.set("Content-Type", contentType);
|
|
168
|
+
return new Response(bytes, { headers });
|
|
169
|
+
}
|
|
170
|
+
throw new TypeError("Too many media redirects.");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Fetches a portable object's external media and returns it only after its
|
|
175
|
+
* SHA-256 `digestMultibase` has been verified. `hl:` resources are tried at
|
|
176
|
+
* the supplied gateways in order. The result contains verified body bytes;
|
|
177
|
+
* its `Content-Type` is unverified metadata supplied by the server.
|
|
178
|
+
*
|
|
179
|
+
* @param media A Link, Image, Document, or URL with an external resource.
|
|
180
|
+
* @param options Retrieval, integrity, and network policy options.
|
|
181
|
+
* @returns A response containing only verified bytes.
|
|
182
|
+
* @throws {TypeError} If the media reference or digest is invalid.
|
|
183
|
+
* @throws {Error} If no source supplies a matching bounded resource.
|
|
184
|
+
* @since 2.4.0
|
|
185
|
+
*/
|
|
186
|
+
export async function fetchPortableMedia(
|
|
187
|
+
media: string | URL | PortableMedia,
|
|
188
|
+
options: FetchPortableMediaOptions = {},
|
|
189
|
+
): Promise<Response> {
|
|
190
|
+
const { url, digestMultibase: propertyDigest } = mediaTarget(media);
|
|
191
|
+
const digestMultibase = propertyDigest ?? options.digestMultibase;
|
|
192
|
+
if (digestMultibase == null) {
|
|
193
|
+
throw new TypeError("Portable media requires digestMultibase.");
|
|
194
|
+
}
|
|
195
|
+
const expected = parseDigestMultibase(digestMultibase).digest;
|
|
196
|
+
const maxBytes = options.maxBytes ?? MAX_BODY_SIZE;
|
|
197
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
|
198
|
+
throw new RangeError(
|
|
199
|
+
"The media size limit must be a positive safe integer.",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const timeout = resolveTimeout(options.timeout, 10_000);
|
|
203
|
+
const gatewayTimeout = resolveTimeout(options.gatewayTimeout, 3_000);
|
|
204
|
+
let targets: URL[];
|
|
205
|
+
let hashlink = false;
|
|
206
|
+
if (/^hl:/i.test(url)) {
|
|
207
|
+
hashlink = true;
|
|
208
|
+
const parsed = parseHashlink(url);
|
|
209
|
+
const hashDigest = parseDigestMultibase(parsed.digestMultibase).digest;
|
|
210
|
+
if (expected.some((byte, i) => byte !== hashDigest[i])) {
|
|
211
|
+
throw new TypeError("Hashlink and digestMultibase disagree.");
|
|
212
|
+
}
|
|
213
|
+
const gateways = [
|
|
214
|
+
...new Set(
|
|
215
|
+
[...options.gateways ?? []].map((gateway) =>
|
|
216
|
+
parseGatewayUrl(gateway.toString()).origin
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
];
|
|
220
|
+
if (gateways.length === 0) {
|
|
221
|
+
throw new TypeError("Hashlink media requires at least one gateway.");
|
|
222
|
+
}
|
|
223
|
+
targets = gateways.map((gateway) =>
|
|
224
|
+
new URL(`/.well-known/apgateway/${url}`, gateway)
|
|
225
|
+
);
|
|
226
|
+
} else {
|
|
227
|
+
const target = new URL(url);
|
|
228
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
229
|
+
throw new TypeError("Portable media URLs must use HTTP(S) or hl:.");
|
|
230
|
+
}
|
|
231
|
+
targets = [target];
|
|
232
|
+
}
|
|
233
|
+
return await withTimeout(
|
|
234
|
+
async (signal) => {
|
|
235
|
+
let lastError: unknown;
|
|
236
|
+
for (const target of targets) {
|
|
237
|
+
signal.throwIfAborted();
|
|
238
|
+
try {
|
|
239
|
+
return await withTimeout(
|
|
240
|
+
(attemptSignal) =>
|
|
241
|
+
fetchVerified(
|
|
242
|
+
target,
|
|
243
|
+
digestMultibase,
|
|
244
|
+
options,
|
|
245
|
+
attemptSignal,
|
|
246
|
+
maxBytes,
|
|
247
|
+
),
|
|
248
|
+
signal,
|
|
249
|
+
hashlink ? gatewayTimeout : null,
|
|
250
|
+
);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
signal.throwIfAborted();
|
|
253
|
+
if (!hashlink) throw error;
|
|
254
|
+
lastError = error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
throw new Error("No gateway returned verified portable media.", {
|
|
258
|
+
cause: lastError,
|
|
259
|
+
});
|
|
260
|
+
},
|
|
261
|
+
options.signal,
|
|
262
|
+
timeout,
|
|
263
|
+
);
|
|
264
|
+
}
|