@plurnk/plurnk-schemes-http 1.3.12 → 1.5.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/.env.defaults +18 -46
- package/README.md +74 -23
- package/SPEC.md +487 -124
- package/dist/Config.d.ts +3 -0
- package/dist/Config.d.ts.map +1 -0
- package/dist/Config.js +18 -0
- package/dist/Config.js.map +1 -0
- package/dist/ContentType.d.ts +2 -0
- package/dist/ContentType.d.ts.map +1 -0
- package/dist/ContentType.js +15 -0
- package/dist/ContentType.js.map +1 -0
- package/dist/ErrorDetail.d.ts +6 -0
- package/dist/ErrorDetail.d.ts.map +1 -0
- package/dist/ErrorDetail.js +19 -0
- package/dist/ErrorDetail.js.map +1 -0
- package/dist/Guard.d.ts +1 -1
- package/dist/Guard.d.ts.map +1 -1
- package/dist/Guard.js +95 -55
- package/dist/Guard.js.map +1 -1
- package/dist/Http.d.ts +3 -16
- package/dist/Http.d.ts.map +1 -1
- package/dist/Http.js +817 -331
- package/dist/Http.js.map +1 -1
- package/dist/TavilyExtract.d.ts +35 -0
- package/dist/TavilyExtract.d.ts.map +1 -0
- package/dist/TavilyExtract.js +204 -0
- package/dist/TavilyExtract.js.map +1 -0
- package/dist/WebFetcher.d.ts +88 -18
- package/dist/WebFetcher.d.ts.map +1 -1
- package/dist/WebFetcher.js +588 -70
- package/dist/WebFetcher.js.map +1 -1
- package/dist/Ws.d.ts +7 -3
- package/dist/Ws.d.ts.map +1 -1
- package/dist/Ws.js +432 -96
- package/dist/Ws.js.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -10
- package/dist/index.js.map +1 -1
- package/docs/http.md +108 -53
- package/docs/wss.md +39 -43
- package/package.json +14 -13
- package/dist/Browser.d.ts +0 -84
- package/dist/Browser.d.ts.map +0 -1
- package/dist/Browser.js +0 -273
- package/dist/Browser.js.map +0 -1
- package/scripts/install-browser.mjs +0 -19
package/dist/WebFetcher.js
CHANGED
|
@@ -1,92 +1,610 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
1
|
+
import { MimetypeClassifier, Results, } from "@plurnk/plurnk-schemes";
|
|
2
|
+
import Guard, { GuardBlockedError } from "./Guard.js";
|
|
3
|
+
import { responseMimetype } from "./ContentType.js";
|
|
4
|
+
import { tavilyConfiguration, tavilyExtract, tavilyExtractIdentity, } from "./TavilyExtract.js";
|
|
5
|
+
import { requirePositiveIntegerEnv } from "./Config.js";
|
|
6
|
+
import ErrorDetail from "./ErrorDetail.js";
|
|
7
|
+
export const DEFAULT_WEB_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
|
8
|
+
export const MARKDOWN_ACCEPT = "text/markdown, text/html;q=0.9, */*;q=0.1";
|
|
9
|
+
export const PROJECTION_ID_HEADER = "x-plurnk-projection-id";
|
|
10
|
+
export const CACHE_VARIANT_HEADER = "x-plurnk-cache-variant";
|
|
11
|
+
export const MATERIALIZER_ID_HEADER = "x-plurnk-materializer-id";
|
|
12
|
+
export const TAVILY_REQUEST_ID_HEADER = "x-plurnk-tavily-request-id";
|
|
13
|
+
export const TAVILY_CREDITS_HEADER = "x-plurnk-tavily-credits";
|
|
14
|
+
export const TAVILY_STATUS_HEADER = "x-plurnk-tavily-status";
|
|
15
|
+
export const TAVILY_REASON_HEADER = "x-plurnk-tavily-reason";
|
|
16
|
+
export const TAVILY_RETRY_AFTER_HEADER = "x-plurnk-tavily-retry-after";
|
|
17
|
+
export const TAVILY_ELAPSED_HEADER = "x-plurnk-tavily-elapsed-ms";
|
|
18
|
+
export const TAVILY_ERROR_HEADER = "x-plurnk-tavily-error";
|
|
19
|
+
export const TAVILY_SOURCE_URL_HEADER = "x-plurnk-tavily-source-url";
|
|
20
|
+
const ORIGIN_MARKDOWN_MATERIALIZER_ID = "origin-markdown:v1";
|
|
21
|
+
const LOCAL_UNCONFIGURED_MATERIALIZER_ID = "local-projection:v1:unconfigured";
|
|
22
|
+
const LOCAL_INELIGIBLE_MATERIALIZER_ID = "local-projection:v1:ineligible";
|
|
23
|
+
export const classifyCacheVariant = (requestHeaders, responseHeaders) => requestHeaders.length === 0
|
|
24
|
+
&& !responseHeaders.some(([name]) => name.toLowerCase() === "vary")
|
|
25
|
+
? "default"
|
|
26
|
+
: "bypass";
|
|
27
|
+
export const cacheVariantEvidence = (variant) => `${CACHE_VARIANT_HEADER}: ${variant}`;
|
|
28
|
+
export const rewriteAcquisitionTarget = (url) => {
|
|
29
|
+
const gh = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/(.+)$/.exec(url);
|
|
30
|
+
return gh === null
|
|
31
|
+
? url
|
|
32
|
+
: `https://raw.githubusercontent.com/${gh[1]}/${gh[2]}/${gh[3]}`;
|
|
33
|
+
};
|
|
34
|
+
export class WebMaterializationError extends Error {
|
|
35
|
+
stage = "projection";
|
|
36
|
+
mimetype;
|
|
37
|
+
constructor(mimetype, cause) {
|
|
38
|
+
super(`Web projection failed for ${mimetype}.`, { cause });
|
|
39
|
+
this.name = "WebMaterializationError";
|
|
40
|
+
this.mimetype = mimetype;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const success = (status = 200) => ({ status });
|
|
44
|
+
const failure = (status, code, detail, retryable, facts) => ({
|
|
45
|
+
status,
|
|
46
|
+
failure: { status, code, detail, retryable, ...(facts === undefined ? {} : { facts }) },
|
|
47
|
+
});
|
|
48
|
+
const safeEvidence = (value) => value.replace(/[\r\n]+/g, " ").trim();
|
|
49
|
+
const hasHeader = (headers, name) => headers.some(([candidate]) => candidate.toLowerCase() === name);
|
|
50
|
+
const replaceHeader = (headers, name, value) => [
|
|
51
|
+
...headers.filter(([candidate]) => candidate.toLowerCase() !== name)
|
|
52
|
+
.map(([candidate, member]) => [candidate, member]),
|
|
53
|
+
[name, value],
|
|
54
|
+
];
|
|
14
55
|
export default class WebFetcher {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
56
|
+
static validateConfiguration() {
|
|
57
|
+
requirePositiveIntegerEnv("PLURNK_SCHEMES_HTTP_FETCH_TIMEOUT");
|
|
58
|
+
tavilyConfiguration();
|
|
59
|
+
}
|
|
60
|
+
static unavailable(url, cause, allowTavily) {
|
|
61
|
+
const detail = ErrorDetail.preview(cause, ErrorDetail.configuredLimit());
|
|
62
|
+
const originFailure = {
|
|
63
|
+
status: 502,
|
|
64
|
+
code: "fetch-failed",
|
|
65
|
+
detail: `HTTP GET ${url} failed: ${detail}`,
|
|
66
|
+
retryable: true,
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
url,
|
|
70
|
+
body: "",
|
|
71
|
+
mimetype: "application/octet-stream",
|
|
72
|
+
header: [
|
|
73
|
+
"Origin unavailable",
|
|
74
|
+
"x-plurnk-request-method: GET",
|
|
75
|
+
`x-plurnk-fetched-at: ${new Date().toISOString()}`,
|
|
76
|
+
cacheVariantEvidence("default"),
|
|
77
|
+
`x-plurnk-origin-error: ${safeEvidence(detail)}`,
|
|
78
|
+
].join("\n"),
|
|
79
|
+
requestHeaders: [],
|
|
80
|
+
allowTavily,
|
|
81
|
+
originUnavailable: true,
|
|
82
|
+
originFailure,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
static materializedChannels(materialized, source) {
|
|
86
|
+
const channels = {};
|
|
87
|
+
if (materialized.body !== undefined) {
|
|
88
|
+
channels.body = {
|
|
89
|
+
...materialized.body,
|
|
90
|
+
...(materialized.bodyOutcome.failure === undefined ? {} : { state: "errored" }),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
else if (materialized.bodyOutcome.failure !== undefined) {
|
|
94
|
+
channels.body = { content: "", mimetype: "text/markdown", state: "errored" };
|
|
95
|
+
}
|
|
96
|
+
if (materialized.header !== undefined) {
|
|
97
|
+
channels.header = { content: materialized.header, mimetype: "text/plain" };
|
|
98
|
+
}
|
|
99
|
+
if (materialized.html !== undefined) {
|
|
100
|
+
channels.html = {
|
|
101
|
+
...materialized.html,
|
|
102
|
+
...(materialized.htmlOutcome?.failure === undefined ? {} : { state: "errored" }),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
else if (materialized.htmlOutcome?.failure !== undefined) {
|
|
106
|
+
channels.html = { content: "", mimetype: "text/html", state: "errored" };
|
|
107
|
+
}
|
|
108
|
+
if (source === undefined)
|
|
109
|
+
return channels;
|
|
110
|
+
const outcomes = {
|
|
111
|
+
body: materialized.bodyOutcome,
|
|
112
|
+
header: materialized.header === undefined
|
|
113
|
+
? failure(502, "header-unavailable", `Acquisition evidence for ${source.url} was unavailable.`, true)
|
|
114
|
+
: success(),
|
|
115
|
+
html: materialized.htmlOutcome ?? (materialized.html === undefined
|
|
116
|
+
? failure(502, "html-unavailable", `Server-source HTML for ${source.url} was unavailable.`, true)
|
|
117
|
+
: success()),
|
|
118
|
+
};
|
|
119
|
+
const defaults = {
|
|
120
|
+
body: "text/markdown",
|
|
121
|
+
header: "text/plain",
|
|
122
|
+
html: "text/html",
|
|
123
|
+
};
|
|
124
|
+
for (const [channel, outcome] of Object.entries(outcomes)) {
|
|
125
|
+
const existing = channels[channel] ?? {
|
|
126
|
+
content: "",
|
|
127
|
+
mimetype: defaults[channel],
|
|
128
|
+
};
|
|
129
|
+
const producerResult = WebFetcher.#producerResult(source, outcome);
|
|
130
|
+
channels[channel] = {
|
|
131
|
+
...existing,
|
|
132
|
+
...(outcome.failure === undefined ? {} : { state: "errored" }),
|
|
133
|
+
...(producerResult === undefined ? {} : { producerResult }),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return channels;
|
|
137
|
+
}
|
|
138
|
+
static #producerResult(source, outcome) {
|
|
139
|
+
if (outcome.failure === undefined) {
|
|
140
|
+
return outcome.status === 200
|
|
141
|
+
? undefined
|
|
142
|
+
: Results.assertChannelProducerResult({ status: outcome.status });
|
|
143
|
+
}
|
|
144
|
+
const { status, code, detail, retryable, facts } = outcome.failure;
|
|
145
|
+
return Results.assertChannelProducerResult(Results.failure("scheme:http", code, status, detail, {}, {
|
|
146
|
+
target: source.url,
|
|
147
|
+
method: source.method,
|
|
148
|
+
stage: "materialization",
|
|
149
|
+
retryable,
|
|
150
|
+
...(facts ?? {}),
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
static async materialize(fetched, projection, signal) {
|
|
154
|
+
// One materializer owns page-body production and independent channel
|
|
155
|
+
// outcomes {§html-materialization}/{§http-channel-outcomes}.
|
|
156
|
+
if (fetched.originUnavailable === true) {
|
|
157
|
+
return WebFetcher.#materializeHtml(fetched, projection, signal);
|
|
158
|
+
}
|
|
159
|
+
const originOutcome = WebFetcher.#originOutcome(fetched);
|
|
160
|
+
if (typeof fetched.body !== "string") {
|
|
161
|
+
const binary = await WebFetcher.classifyBinary(fetched.body, fetched.mimetype, projection);
|
|
162
|
+
if (binary) {
|
|
163
|
+
const projected = await WebFetcher.projectBytes(fetched.body, fetched.mimetype, projection);
|
|
164
|
+
if (projected === null)
|
|
165
|
+
return null;
|
|
166
|
+
return WebFetcher.#materialized(projected, {
|
|
167
|
+
html: fetched.html,
|
|
168
|
+
header: fetched.header,
|
|
169
|
+
bodyOutcome: originOutcome,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const content = await fetched.body.text();
|
|
173
|
+
return {
|
|
174
|
+
body: { content, mimetype: fetched.mimetype },
|
|
175
|
+
...(fetched.html === undefined ? {} : { html: fetched.html }),
|
|
176
|
+
...(fetched.header === undefined ? {} : { header: fetched.header }),
|
|
177
|
+
bodyOutcome: originOutcome,
|
|
178
|
+
...(fetched.html === undefined ? {} : { htmlOutcome: success() }),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (fetched.mimetype === "text/markdown") {
|
|
182
|
+
const materializedHeader = WebFetcher.#appendEvidence(fetched.header, [
|
|
183
|
+
WebFetcher.materializerEvidence(ORIGIN_MARKDOWN_MATERIALIZER_ID),
|
|
184
|
+
]);
|
|
185
|
+
return {
|
|
186
|
+
body: { content: fetched.body, mimetype: "text/markdown" },
|
|
187
|
+
...(fetched.html === undefined ? {} : { html: fetched.html }),
|
|
188
|
+
...(materializedHeader === undefined ? {} : { header: materializedHeader }),
|
|
189
|
+
bodyOutcome: originOutcome,
|
|
190
|
+
htmlOutcome: fetched.html === undefined
|
|
191
|
+
? { status: fetched.htmlFailure?.status ?? 502, failure: fetched.htmlFailure ?? {
|
|
192
|
+
status: 502,
|
|
193
|
+
code: "html-variant-unavailable",
|
|
194
|
+
detail: `The HTML variant of ${fetched.url} was unavailable.`,
|
|
195
|
+
retryable: true,
|
|
196
|
+
} }
|
|
197
|
+
: success(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (!MimetypeClassifier.isHtml(fetched.mimetype)) {
|
|
201
|
+
return {
|
|
202
|
+
body: { content: fetched.body, mimetype: fetched.mimetype },
|
|
203
|
+
...(fetched.header === undefined ? {} : { header: fetched.header }),
|
|
204
|
+
bodyOutcome: originOutcome,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return WebFetcher.#materializeHtml(fetched, projection, signal);
|
|
208
|
+
}
|
|
209
|
+
static async #materializeHtml(fetched, projection, signal) {
|
|
210
|
+
const html = fetched.originUnavailable === true
|
|
211
|
+
? undefined
|
|
212
|
+
: { content: fetched.body, mimetype: fetched.mimetype };
|
|
213
|
+
const originOutcome = WebFetcher.#originOutcome(fetched);
|
|
214
|
+
const tavilyIdentity = fetched.allowTavily === true
|
|
215
|
+
? tavilyExtractIdentity()
|
|
216
|
+
: null;
|
|
217
|
+
if (tavilyIdentity !== null) {
|
|
218
|
+
const tavily = await tavilyExtract(fetched.url, { signal });
|
|
219
|
+
if (tavily === null)
|
|
220
|
+
throw new Error("Tavily configuration disappeared during materialization.");
|
|
221
|
+
const evidence = [
|
|
222
|
+
WebFetcher.materializerEvidence(tavily.outcome === "success"
|
|
223
|
+
? tavilyIdentity
|
|
224
|
+
: `local-fallback:${tavilyIdentity}`),
|
|
225
|
+
...WebFetcher.#tavilyEvidence(tavily),
|
|
226
|
+
];
|
|
227
|
+
if (tavily.outcome === "success") {
|
|
228
|
+
const header = WebFetcher.#appendEvidence(fetched.header, evidence);
|
|
229
|
+
return {
|
|
230
|
+
body: { content: tavily.markdown, mimetype: "text/markdown" },
|
|
231
|
+
...(html === undefined ? {} : { html }),
|
|
232
|
+
...(header === undefined ? {} : { header }),
|
|
233
|
+
bodyOutcome: success(),
|
|
234
|
+
htmlOutcome: html === undefined
|
|
235
|
+
? failure(502, "html-unavailable", `Server-source HTML for ${fetched.url} was unavailable.`, true)
|
|
236
|
+
: originOutcome,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (tavily.outcome === "hard" || html === undefined) {
|
|
240
|
+
const header = WebFetcher.#appendEvidence(fetched.header, [
|
|
241
|
+
WebFetcher.materializerEvidence(tavilyIdentity),
|
|
242
|
+
...WebFetcher.#tavilyEvidence(tavily),
|
|
243
|
+
]);
|
|
244
|
+
return {
|
|
245
|
+
...(html === undefined ? {} : { html }),
|
|
246
|
+
...(header === undefined ? {} : { header }),
|
|
247
|
+
bodyOutcome: WebFetcher.#tavilyFailure(fetched.url, tavily),
|
|
248
|
+
htmlOutcome: html === undefined
|
|
249
|
+
? failure(502, "html-unavailable", `Server-source HTML for ${fetched.url} was unavailable.`, true)
|
|
250
|
+
: originOutcome,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const projected = await WebFetcher.#project(html, projection);
|
|
254
|
+
if (projected === null) {
|
|
255
|
+
return {
|
|
256
|
+
html,
|
|
257
|
+
header: WebFetcher.#appendEvidence(fetched.header, evidence),
|
|
258
|
+
bodyOutcome: WebFetcher.#tavilyFailure(fetched.url, tavily),
|
|
259
|
+
htmlOutcome: originOutcome,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return WebFetcher.#materialized(projected, {
|
|
263
|
+
html,
|
|
264
|
+
header: fetched.header,
|
|
265
|
+
materializerIdentity: `local-fallback:${tavilyIdentity}`,
|
|
266
|
+
additionalEvidence: WebFetcher.#tavilyEvidence(tavily),
|
|
267
|
+
bodyOutcome: originOutcome.failure === undefined
|
|
268
|
+
? success(203)
|
|
269
|
+
: originOutcome,
|
|
270
|
+
htmlOutcome: originOutcome,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (html === undefined) {
|
|
274
|
+
return {
|
|
275
|
+
...(fetched.header === undefined ? {} : { header: fetched.header }),
|
|
276
|
+
bodyOutcome: fetched.originFailure === undefined
|
|
277
|
+
? failure(502, "origin-unavailable", `The origin ${fetched.url} was unavailable.`, true)
|
|
278
|
+
: { status: fetched.originFailure.status, failure: fetched.originFailure },
|
|
279
|
+
htmlOutcome: failure(502, "html-unavailable", `Server-source HTML for ${fetched.url} was unavailable.`, true),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const projected = await WebFetcher.#project(html, projection);
|
|
283
|
+
const materializerIdentity = fetched.allowTavily === true
|
|
284
|
+
? LOCAL_UNCONFIGURED_MATERIALIZER_ID
|
|
285
|
+
: LOCAL_INELIGIBLE_MATERIALIZER_ID;
|
|
286
|
+
if (projected === null) {
|
|
287
|
+
return {
|
|
288
|
+
html,
|
|
289
|
+
header: WebFetcher.#appendEvidence(fetched.header, [
|
|
290
|
+
WebFetcher.materializerEvidence(materializerIdentity),
|
|
291
|
+
]),
|
|
292
|
+
bodyOutcome: failure(422, "no-readable-projection", `The HTML representation of ${fetched.url} produced no readable body.`, false),
|
|
293
|
+
htmlOutcome: originOutcome,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return WebFetcher.#materialized(projected, {
|
|
297
|
+
html,
|
|
298
|
+
header: fetched.header,
|
|
299
|
+
materializerIdentity,
|
|
300
|
+
bodyOutcome: originOutcome,
|
|
301
|
+
htmlOutcome: originOutcome,
|
|
302
|
+
});
|
|
20
303
|
}
|
|
21
|
-
|
|
22
|
-
|
|
304
|
+
static #originOutcome(fetched) {
|
|
305
|
+
const status = fetched.status ?? 200;
|
|
306
|
+
if (status < 400)
|
|
307
|
+
return success(status);
|
|
308
|
+
const statusText = fetched.statusText?.trim() ?? "";
|
|
309
|
+
return failure(status, "http-response-status", `HTTP GET ${fetched.url} returned ${status}${statusText === "" ? "" : ` ${statusText}`}.`, status === 408 || status === 425 || status === 429 || status >= 500, {
|
|
310
|
+
originStatus: status,
|
|
311
|
+
...(statusText === "" ? {} : { originStatusText: statusText }),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
static #tavilyFailure(url, result) {
|
|
315
|
+
const facts = {
|
|
316
|
+
provider: "tavily",
|
|
317
|
+
reason: result.reason,
|
|
318
|
+
...(result.status === undefined ? {} : { providerStatus: result.status }),
|
|
319
|
+
...(result.requestId === undefined ? {} : { requestId: result.requestId }),
|
|
320
|
+
...(result.retryAfter === undefined ? {} : { retryAfter: result.retryAfter }),
|
|
321
|
+
};
|
|
322
|
+
if (result.reason === "rate-limit") {
|
|
323
|
+
return failure(429, "tavily-rate-limited", `Tavily Extract rate-limited ${url}.`, true, facts);
|
|
324
|
+
}
|
|
325
|
+
if (result.reason === "timeout") {
|
|
326
|
+
return failure(504, "tavily-timeout", `Tavily Extract timed out for ${url}.`, true, facts);
|
|
327
|
+
}
|
|
328
|
+
if (result.reason === "authentication") {
|
|
329
|
+
return failure(502, "tavily-authentication-failed", "Tavily rejected the configured API credentials.", false, facts);
|
|
330
|
+
}
|
|
331
|
+
if (result.reason === "malformed-response") {
|
|
332
|
+
return failure(502, "tavily-invalid-response", "Tavily returned an invalid Extract response.", false, facts);
|
|
333
|
+
}
|
|
334
|
+
if (result.reason === "provider-rejection") {
|
|
335
|
+
return failure(502, "tavily-provider-rejected", `Tavily rejected extraction for ${url}.`, false, facts);
|
|
336
|
+
}
|
|
337
|
+
return failure(502, `tavily-${result.reason}`, `Tavily Extract failed for ${url}.`, true, facts);
|
|
338
|
+
}
|
|
339
|
+
static #tavilyEvidence(result) {
|
|
340
|
+
return [
|
|
341
|
+
`${TAVILY_STATUS_HEADER}: ${result.status ?? "transport-failure"}`,
|
|
342
|
+
`${TAVILY_ELAPSED_HEADER}: ${result.elapsedMs}`,
|
|
343
|
+
result.requestId === undefined ? null : `${TAVILY_REQUEST_ID_HEADER}: ${safeEvidence(result.requestId)}`,
|
|
344
|
+
result.credits === undefined ? null : `${TAVILY_CREDITS_HEADER}: ${result.credits}`,
|
|
345
|
+
result.outcome === "success" || result.retryAfter === undefined
|
|
346
|
+
? null
|
|
347
|
+
: `${TAVILY_RETRY_AFTER_HEADER}: ${safeEvidence(result.retryAfter)}`,
|
|
348
|
+
result.outcome === "success"
|
|
349
|
+
? result.sourceUrl === undefined ? null : `${TAVILY_SOURCE_URL_HEADER}: ${safeEvidence(result.sourceUrl)}`
|
|
350
|
+
: `${TAVILY_REASON_HEADER}: ${result.reason}`,
|
|
351
|
+
result.outcome === "success" || result.error === undefined
|
|
352
|
+
? null
|
|
353
|
+
: `${TAVILY_ERROR_HEADER}: ${safeEvidence(result.error)}`,
|
|
354
|
+
].filter((line) => line !== null);
|
|
355
|
+
}
|
|
356
|
+
static async classifyBinary(body, mimetype, projection) {
|
|
357
|
+
try {
|
|
358
|
+
return await projection.isBinary(mimetype);
|
|
359
|
+
}
|
|
360
|
+
catch (cause) {
|
|
361
|
+
return await WebFetcher.#projectionFailure(body, mimetype, cause);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
static async projectBytes(body, mimetype, projection) {
|
|
365
|
+
let projected;
|
|
366
|
+
try {
|
|
367
|
+
projected = await projection.readableBytes(body.chunks, mimetype);
|
|
368
|
+
}
|
|
369
|
+
catch (cause) {
|
|
370
|
+
return await WebFetcher.#projectionFailure(body, mimetype, cause);
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
await body.cancel();
|
|
374
|
+
}
|
|
375
|
+
catch (cause) {
|
|
376
|
+
throw new WebMaterializationError(mimetype, cause);
|
|
377
|
+
}
|
|
378
|
+
return projected;
|
|
379
|
+
}
|
|
380
|
+
static async #projectionFailure(body, mimetype, cause) {
|
|
381
|
+
let failureCause = cause;
|
|
382
|
+
try {
|
|
383
|
+
await body.cancel();
|
|
384
|
+
}
|
|
385
|
+
catch (cleanupCause) {
|
|
386
|
+
failureCause = new AggregateError([cause, cleanupCause], `Projection and response-body cleanup both failed for ${mimetype}.`);
|
|
387
|
+
}
|
|
388
|
+
throw new WebMaterializationError(mimetype, failureCause);
|
|
389
|
+
}
|
|
390
|
+
static async #project(html, projection) {
|
|
391
|
+
try {
|
|
392
|
+
return await projection.readable(html.content, html.mimetype);
|
|
393
|
+
}
|
|
394
|
+
catch (cause) {
|
|
395
|
+
throw new WebMaterializationError(html.mimetype, cause);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
static #materialized(projected, options = {}) {
|
|
399
|
+
const { html, header, materializerIdentity, additionalEvidence = [], bodyOutcome = success(), htmlOutcome = success(), } = options;
|
|
400
|
+
const evidence = [
|
|
401
|
+
...(materializerIdentity === undefined
|
|
402
|
+
? []
|
|
403
|
+
: [WebFetcher.materializerEvidence(materializerIdentity)]),
|
|
404
|
+
...additionalEvidence,
|
|
405
|
+
WebFetcher.projectionEvidence(projected.projectionIdentity),
|
|
406
|
+
];
|
|
407
|
+
const materializedHeader = WebFetcher.#appendEvidence(header, evidence);
|
|
408
|
+
return {
|
|
409
|
+
body: { content: projected.content, mimetype: projected.mimetype },
|
|
410
|
+
...(html === undefined ? {} : { html }),
|
|
411
|
+
...(materializedHeader === undefined
|
|
412
|
+
? {}
|
|
413
|
+
: { header: materializedHeader }),
|
|
414
|
+
bodyOutcome,
|
|
415
|
+
...(html === undefined ? {} : { htmlOutcome }),
|
|
416
|
+
projection: {
|
|
417
|
+
sourceMimetype: projected.sourceMimetype,
|
|
418
|
+
identity: projected.projectionIdentity,
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
static #appendEvidence(header, evidence) {
|
|
423
|
+
const lines = [header, ...evidence].filter((line) => line !== undefined && line.length > 0);
|
|
424
|
+
return lines.length === 0 ? undefined : lines.join("\n");
|
|
425
|
+
}
|
|
426
|
+
static projectionEvidence(identity) {
|
|
427
|
+
return `${PROJECTION_ID_HEADER}: ${identity}`;
|
|
428
|
+
}
|
|
429
|
+
static materializerEvidence(identity) {
|
|
430
|
+
return `${MATERIALIZER_ID_HEADER}: ${identity}`;
|
|
431
|
+
}
|
|
432
|
+
static materializerCurrent(storedIdentity) {
|
|
433
|
+
const tavilyIdentity = tavilyExtractIdentity();
|
|
434
|
+
if (storedIdentity === ORIGIN_MARKDOWN_MATERIALIZER_ID
|
|
435
|
+
|| storedIdentity === LOCAL_INELIGIBLE_MATERIALIZER_ID)
|
|
436
|
+
return true;
|
|
437
|
+
if (storedIdentity === LOCAL_UNCONFIGURED_MATERIALIZER_ID)
|
|
438
|
+
return tavilyIdentity === null;
|
|
439
|
+
return tavilyIdentity !== null
|
|
440
|
+
&& (storedIdentity === tavilyIdentity
|
|
441
|
+
|| storedIdentity === `local-fallback:${tavilyIdentity}`);
|
|
23
442
|
}
|
|
24
443
|
async fetch(url, opts) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
444
|
+
opts?.signal?.throwIfAborted();
|
|
445
|
+
const target = rewriteAcquisitionTarget(url);
|
|
446
|
+
const requestHeaders = opts?.headers ?? [];
|
|
447
|
+
const authoredAccept = hasHeader(requestHeaders, "accept");
|
|
448
|
+
const conditionalHeaders = opts?.conditionalHeaders ?? [];
|
|
449
|
+
let transportHeaders = requestHeaders.map(([name, value]) => [name, value]);
|
|
450
|
+
if (!hasHeader(transportHeaders, "accept")) {
|
|
451
|
+
transportHeaders = [["Accept", MARKDOWN_ACCEPT], ...transportHeaders];
|
|
452
|
+
}
|
|
453
|
+
if (!hasHeader(transportHeaders, "user-agent")) {
|
|
454
|
+
transportHeaders = [["User-Agent", DEFAULT_WEB_UA], ...transportHeaders];
|
|
455
|
+
}
|
|
456
|
+
transportHeaders.push(...conditionalHeaders.map(([name, value]) => [name, value]));
|
|
457
|
+
const fetchTimeout = requirePositiveIntegerEnv("PLURNK_SCHEMES_HTTP_FETCH_TIMEOUT");
|
|
458
|
+
const timeout = AbortSignal.timeout(fetchTimeout);
|
|
459
|
+
const signal = opts?.signal === undefined ? timeout : AbortSignal.any([opts.signal, timeout]);
|
|
460
|
+
const guarded = opts?.guarded !== false;
|
|
461
|
+
const request = async (requestUrl, headers) => guarded
|
|
462
|
+
? Guard.fetch(requestUrl, { method: "GET", body: undefined, headers }, signal)
|
|
463
|
+
: fetch(requestUrl, {
|
|
464
|
+
method: "GET",
|
|
465
|
+
headers: headers.map(([name, value]) => [name, value]),
|
|
466
|
+
signal,
|
|
467
|
+
redirect: "follow",
|
|
468
|
+
});
|
|
33
469
|
let response;
|
|
34
470
|
try {
|
|
35
|
-
response = await
|
|
471
|
+
response = await request(target, transportHeaders);
|
|
36
472
|
}
|
|
37
|
-
catch {
|
|
38
|
-
|
|
473
|
+
catch (cause) {
|
|
474
|
+
if (opts?.signal?.aborted === true)
|
|
475
|
+
throw opts.signal.reason;
|
|
476
|
+
if (cause instanceof GuardBlockedError)
|
|
477
|
+
return null;
|
|
478
|
+
const publiclyAdmitted = guarded || await Guard.isPublicUrl(target);
|
|
479
|
+
const allowTavily = requestHeaders.length === 0 && publiclyAdmitted;
|
|
480
|
+
return (allowTavily && tavilyExtractIdentity() !== null)
|
|
481
|
+
|| opts?.preserveUnavailable === true
|
|
482
|
+
? WebFetcher.unavailable(url, cause, allowTavily)
|
|
483
|
+
: null;
|
|
39
484
|
}
|
|
40
|
-
|
|
41
|
-
if (!response.ok) {
|
|
485
|
+
if (opts?.acceptHttpErrors !== true && !response.ok) {
|
|
42
486
|
await response.body?.cancel();
|
|
43
487
|
return null;
|
|
44
|
-
}
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
488
|
+
}
|
|
489
|
+
const responseHeaders = [...response.headers];
|
|
490
|
+
const mimetype = responseMimetype(response.headers.get("content-type"));
|
|
491
|
+
const tavilyConfigured = tavilyExtractIdentity() !== null;
|
|
492
|
+
const publiclyAdmitted = guarded
|
|
493
|
+
|| !tavilyConfigured
|
|
494
|
+
|| (requestHeaders.length === 0 && await Guard.isPublicUrl(target));
|
|
495
|
+
const allowTavily = requestHeaders.length === 0 && publiclyAdmitted;
|
|
496
|
+
const header = WebFetcher.#header(response, requestHeaders, url);
|
|
497
|
+
const common = {
|
|
498
|
+
url,
|
|
499
|
+
mimetype,
|
|
500
|
+
status: response.status,
|
|
501
|
+
statusText: response.statusText,
|
|
502
|
+
responseHeaders,
|
|
503
|
+
response,
|
|
504
|
+
header,
|
|
505
|
+
requestHeaders,
|
|
506
|
+
allowTavily,
|
|
507
|
+
};
|
|
508
|
+
const unavailableAfterResponse = (cause) => {
|
|
509
|
+
if (opts?.signal?.aborted === true)
|
|
510
|
+
throw opts.signal.reason;
|
|
511
|
+
if (!(allowTavily && tavilyConfigured) && opts?.preserveUnavailable !== true)
|
|
54
512
|
return null;
|
|
513
|
+
const unavailable = WebFetcher.unavailable(url, cause, allowTavily);
|
|
514
|
+
const detail = unavailable.originFailure?.detail ?? `HTTP GET ${url} failed.`;
|
|
55
515
|
return {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
header,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// Caller cancellation still spans the whole operation.
|
|
64
|
-
// The renderer supplies its own per-navigation deadline.
|
|
65
|
-
signal: opts?.signal,
|
|
66
|
-
headers: [["User-Agent", BROWSER_UA]],
|
|
67
|
-
guard: Guard.isPublicUrl,
|
|
68
|
-
});
|
|
69
|
-
return rendered.html.length > 0 ? { body: rendered.html, mimetype: "text/html" } : null;
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
},
|
|
516
|
+
...common,
|
|
517
|
+
body: "",
|
|
518
|
+
header: WebFetcher.#appendEvidence(common.header, [
|
|
519
|
+
`x-plurnk-origin-error: ${safeEvidence(detail)}`,
|
|
520
|
+
]),
|
|
521
|
+
originUnavailable: true,
|
|
522
|
+
originFailure: unavailable.originFailure,
|
|
75
523
|
};
|
|
524
|
+
};
|
|
525
|
+
let pageBody;
|
|
526
|
+
if (MimetypeClassifier.isHtml(mimetype) || mimetype === "text/markdown") {
|
|
527
|
+
try {
|
|
528
|
+
pageBody = await response.text();
|
|
529
|
+
}
|
|
530
|
+
catch (cause) {
|
|
531
|
+
return unavailableAfterResponse(cause);
|
|
532
|
+
}
|
|
76
533
|
}
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
534
|
+
if (MimetypeClassifier.isHtml(mimetype)) {
|
|
535
|
+
return { ...common, body: pageBody };
|
|
536
|
+
}
|
|
537
|
+
if (mimetype === "text/markdown") {
|
|
538
|
+
const markdown = pageBody;
|
|
539
|
+
if (authoredAccept)
|
|
540
|
+
return { ...common, body: markdown };
|
|
541
|
+
let html;
|
|
542
|
+
let htmlFailure;
|
|
543
|
+
const variantHeaders = replaceHeader(transportHeaders, "accept", "text/html");
|
|
544
|
+
try {
|
|
545
|
+
const variant = await request(target, variantHeaders);
|
|
546
|
+
const variantMimetype = responseMimetype(variant.headers.get("content-type"));
|
|
547
|
+
if (variant.ok && MimetypeClassifier.isHtml(variantMimetype)) {
|
|
548
|
+
html = { content: await variant.text(), mimetype: variantMimetype };
|
|
549
|
+
common.header = WebFetcher.#appendEvidence(common.header, WebFetcher.#htmlVariantEvidence(variant, url));
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
await variant.body?.cancel();
|
|
553
|
+
htmlFailure = {
|
|
554
|
+
status: 502,
|
|
555
|
+
code: "html-variant-unavailable",
|
|
556
|
+
detail: `The origin did not provide an HTML variant of ${url}.`,
|
|
557
|
+
retryable: true,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
catch (cause) {
|
|
562
|
+
if (opts?.signal?.aborted === true)
|
|
563
|
+
throw opts.signal.reason;
|
|
564
|
+
htmlFailure = {
|
|
565
|
+
status: 502,
|
|
566
|
+
code: "html-variant-unavailable",
|
|
567
|
+
detail: `The HTML variant of ${url} could not be acquired: ${ErrorDetail.preview(cause, ErrorDetail.configuredLimit())}`,
|
|
568
|
+
retryable: true,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
...common,
|
|
573
|
+
body: markdown,
|
|
574
|
+
...(html === undefined ? {} : { html }),
|
|
575
|
+
...(htmlFailure === undefined ? {} : { htmlFailure }),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
if (response.body === null)
|
|
579
|
+
return { ...common, body: "" };
|
|
580
|
+
const responseBody = response.body;
|
|
581
|
+
return {
|
|
582
|
+
...common,
|
|
583
|
+
body: {
|
|
584
|
+
chunks: responseBody,
|
|
585
|
+
text: () => response.text(),
|
|
586
|
+
cancel: () => responseBody.cancel(),
|
|
587
|
+
},
|
|
588
|
+
};
|
|
83
589
|
}
|
|
84
|
-
static #header(response) {
|
|
590
|
+
static #header(response, requestHeaders, addressedUrl) {
|
|
591
|
+
const responseHeaders = [...response.headers];
|
|
85
592
|
return [
|
|
86
593
|
`HTTP ${response.status} ${response.statusText}`,
|
|
87
|
-
...
|
|
594
|
+
...responseHeaders.map(([key, value]) => `${key}: ${value}`),
|
|
595
|
+
"x-plurnk-request-method: GET",
|
|
88
596
|
`x-plurnk-fetched-at: ${new Date().toISOString()}`,
|
|
597
|
+
`x-plurnk-response-url: ${safeEvidence(response.url || addressedUrl)}`,
|
|
598
|
+
cacheVariantEvidence(classifyCacheVariant(requestHeaders, responseHeaders)),
|
|
89
599
|
].join("\n");
|
|
90
600
|
}
|
|
601
|
+
static #htmlVariantEvidence(response, addressedUrl) {
|
|
602
|
+
return [
|
|
603
|
+
`x-plurnk-html-status: ${response.status}`,
|
|
604
|
+
`x-plurnk-html-response-url: ${safeEvidence(response.url || addressedUrl)}`,
|
|
605
|
+
`x-plurnk-html-fetched-at: ${new Date().toISOString()}`,
|
|
606
|
+
...[...response.headers].map(([name, value]) => `x-plurnk-html-response-header: ${safeEvidence(name)}: ${safeEvidence(value)}`),
|
|
607
|
+
];
|
|
608
|
+
}
|
|
91
609
|
}
|
|
92
610
|
//# sourceMappingURL=WebFetcher.js.map
|