@plurnk/plurnk-schemes-http 1.4.0 → 1.6.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 +10 -40
- package/README.md +38 -34
- package/SPEC.md +260 -227
- 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/Guard.d.ts.map +1 -1
- package/dist/Guard.js +2 -3
- package/dist/Guard.js.map +1 -1
- package/dist/Http.d.ts +5 -18
- package/dist/Http.d.ts.map +1 -1
- package/dist/Http.js +216 -233
- 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 +58 -24
- package/dist/WebFetcher.d.ts.map +1 -1
- package/dist/WebFetcher.js +485 -108
- package/dist/WebFetcher.js.map +1 -1
- package/dist/Ws.d.ts +3 -2
- package/dist/Ws.d.ts.map +1 -1
- package/dist/Ws.js +132 -94
- package/dist/Ws.js.map +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/docs/http.md +74 -59
- package/docs/wss.md +19 -14
- package/package.json +3 -5
- package/dist/Browser.d.ts +0 -75
- package/dist/Browser.d.ts.map +0 -1
- package/dist/Browser.js +0 -295
- package/dist/Browser.js.map +0 -1
package/dist/WebFetcher.js
CHANGED
|
@@ -1,90 +1,357 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// readable projection is absent. Top-level deadness is the `null` value; caller
|
|
4
|
-
// cancellation rejects with that signal's exact reason.
|
|
5
|
-
import { MimetypeClassifier, } from "@plurnk/plurnk-schemes";
|
|
6
|
-
import Browser, { BROWSER_UA, requireNumEnv } from "./Browser.js";
|
|
7
|
-
import Guard from "./Guard.js";
|
|
1
|
+
import { MimetypeClassifier, Results, } from "@plurnk/plurnk-schemes";
|
|
2
|
+
import Guard, { GuardBlockedError } from "./Guard.js";
|
|
8
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
9
|
export const PROJECTION_ID_HEADER = "x-plurnk-projection-id";
|
|
10
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";
|
|
11
23
|
export const classifyCacheVariant = (requestHeaders, responseHeaders) => requestHeaders.length === 0
|
|
12
24
|
&& !responseHeaders.some(([name]) => name.toLowerCase() === "vary")
|
|
13
25
|
? "default"
|
|
14
26
|
: "bypass";
|
|
15
27
|
export const cacheVariantEvidence = (variant) => `${CACHE_VARIANT_HEADER}: ${variant}`;
|
|
16
|
-
// {§host-rewrite} — one transport-only policy for acquisition GETs. Callers
|
|
17
|
-
// retain the addressed URL as entry identity; mutations never pass through it.
|
|
18
28
|
export const rewriteAcquisitionTarget = (url) => {
|
|
19
29
|
const gh = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/(.+)$/.exec(url);
|
|
20
30
|
return gh === null
|
|
21
31
|
? url
|
|
22
32
|
: `https://raw.githubusercontent.com/${gh[1]}/${gh[2]}/${gh[3]}`;
|
|
23
33
|
};
|
|
24
|
-
// {§html-materialization} Preserve the failing stage and original cause while
|
|
25
|
-
// keeping an expected absent projection in the ordinary null result channel.
|
|
26
34
|
export class WebMaterializationError extends Error {
|
|
27
|
-
stage;
|
|
35
|
+
stage = "projection";
|
|
28
36
|
mimetype;
|
|
29
|
-
constructor(
|
|
30
|
-
super(`Web
|
|
37
|
+
constructor(mimetype, cause) {
|
|
38
|
+
super(`Web projection failed for ${mimetype}.`, { cause });
|
|
31
39
|
this.name = "WebMaterializationError";
|
|
32
|
-
this.stage = stage;
|
|
33
40
|
this.mimetype = mimetype;
|
|
34
41
|
}
|
|
35
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
|
+
];
|
|
36
55
|
export default class WebFetcher {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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);
|
|
48
160
|
if (typeof fetched.body !== "string") {
|
|
49
161
|
const binary = await WebFetcher.classifyBinary(fetched.body, fetched.mimetype, projection);
|
|
50
162
|
if (binary) {
|
|
51
163
|
const projected = await WebFetcher.projectBytes(fetched.body, fetched.mimetype, projection);
|
|
52
164
|
if (projected === null)
|
|
53
165
|
return null;
|
|
54
|
-
return WebFetcher.#materialized(projected,
|
|
166
|
+
return WebFetcher.#materialized(projected, {
|
|
167
|
+
html: fetched.html,
|
|
168
|
+
header: fetched.header,
|
|
169
|
+
bodyOutcome: originOutcome,
|
|
170
|
+
});
|
|
55
171
|
}
|
|
56
172
|
const content = await fetched.body.text();
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
: {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
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
|
+
};
|
|
63
199
|
}
|
|
64
200
|
if (!MimetypeClassifier.isHtml(fetched.mimetype)) {
|
|
65
201
|
return {
|
|
66
202
|
body: { content: fetched.body, mimetype: fetched.mimetype },
|
|
67
203
|
...(fetched.header === undefined ? {} : { header: fetched.header }),
|
|
204
|
+
bodyOutcome: originOutcome,
|
|
68
205
|
};
|
|
69
206
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
};
|
|
76
238
|
}
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
};
|
|
79
252
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
};
|
|
83
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
|
+
});
|
|
303
|
+
}
|
|
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);
|
|
84
324
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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);
|
|
88
355
|
}
|
|
89
356
|
static async classifyBinary(body, mimetype, projection) {
|
|
90
357
|
try {
|
|
@@ -106,128 +373,238 @@ export default class WebFetcher {
|
|
|
106
373
|
await body.cancel();
|
|
107
374
|
}
|
|
108
375
|
catch (cause) {
|
|
109
|
-
throw new WebMaterializationError(
|
|
376
|
+
throw new WebMaterializationError(mimetype, cause);
|
|
110
377
|
}
|
|
111
378
|
return projected;
|
|
112
379
|
}
|
|
113
380
|
static async #projectionFailure(body, mimetype, cause) {
|
|
114
|
-
let
|
|
381
|
+
let failureCause = cause;
|
|
115
382
|
try {
|
|
116
383
|
await body.cancel();
|
|
117
384
|
}
|
|
118
385
|
catch (cleanupCause) {
|
|
119
|
-
|
|
386
|
+
failureCause = new AggregateError([cause, cleanupCause], `Projection and response-body cleanup both failed for ${mimetype}.`);
|
|
120
387
|
}
|
|
121
|
-
throw new WebMaterializationError(
|
|
388
|
+
throw new WebMaterializationError(mimetype, failureCause);
|
|
122
389
|
}
|
|
123
390
|
static async #project(html, projection) {
|
|
124
391
|
try {
|
|
125
392
|
return await projection.readable(html.content, html.mimetype);
|
|
126
393
|
}
|
|
127
394
|
catch (cause) {
|
|
128
|
-
throw new WebMaterializationError(
|
|
395
|
+
throw new WebMaterializationError(html.mimetype, cause);
|
|
129
396
|
}
|
|
130
397
|
}
|
|
131
|
-
static #materialized(projected,
|
|
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);
|
|
132
408
|
return {
|
|
133
409
|
body: { content: projected.content, mimetype: projected.mimetype },
|
|
134
410
|
...(html === undefined ? {} : { html }),
|
|
135
|
-
...(
|
|
136
|
-
|
|
137
|
-
|
|
411
|
+
...(materializedHeader === undefined
|
|
412
|
+
? {}
|
|
413
|
+
: { header: materializedHeader }),
|
|
414
|
+
bodyOutcome,
|
|
415
|
+
...(html === undefined ? {} : { htmlOutcome }),
|
|
138
416
|
projection: {
|
|
139
417
|
sourceMimetype: projected.sourceMimetype,
|
|
140
418
|
identity: projected.projectionIdentity,
|
|
141
419
|
},
|
|
142
420
|
};
|
|
143
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
|
+
}
|
|
144
426
|
static projectionEvidence(identity) {
|
|
145
427
|
return `${PROJECTION_ID_HEADER}: ${identity}`;
|
|
146
428
|
}
|
|
147
|
-
|
|
148
|
-
|
|
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}`);
|
|
149
442
|
}
|
|
150
443
|
async fetch(url, opts) {
|
|
151
444
|
opts?.signal?.throwIfAborted();
|
|
152
445
|
const target = rewriteAcquisitionTarget(url);
|
|
153
446
|
const requestHeaders = opts?.headers ?? [];
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
});
|
|
161
469
|
let response;
|
|
162
470
|
try {
|
|
163
|
-
response = await
|
|
164
|
-
method: "GET",
|
|
165
|
-
body: undefined,
|
|
166
|
-
headers: transportHeaders,
|
|
167
|
-
}, probeSignal);
|
|
168
|
-
}
|
|
169
|
-
catch {
|
|
170
|
-
// {§prefetch} AbortSignal.any preserves the first winning reason.
|
|
171
|
-
// Only a caller-owned win escapes; the probe deadline remains the
|
|
172
|
-
// primitive's ordinary dead/liveness result.
|
|
173
|
-
if (opts?.signal?.aborted === true && probeSignal.reason === opts.signal.reason) {
|
|
174
|
-
opts.signal.throwIfAborted();
|
|
175
|
-
}
|
|
176
|
-
return null; // refused or unreachable — both unavailable
|
|
471
|
+
response = await request(target, transportHeaders);
|
|
177
472
|
}
|
|
178
|
-
|
|
179
|
-
|
|
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;
|
|
484
|
+
}
|
|
485
|
+
if (opts?.acceptHttpErrors !== true && !response.ok) {
|
|
180
486
|
await response.body?.cancel();
|
|
181
487
|
return null;
|
|
182
|
-
}
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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)
|
|
189
512
|
return null;
|
|
513
|
+
const unavailable = WebFetcher.unavailable(url, cause, allowTavily);
|
|
514
|
+
const detail = unavailable.originFailure?.detail ?? `HTTP GET ${url} failed.`;
|
|
190
515
|
return {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
header,
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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,
|
|
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
|
+
}
|
|
533
|
+
}
|
|
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 }),
|
|
204
576
|
};
|
|
205
577
|
}
|
|
206
|
-
// Preserve one unconsumed response body until the consumer's configured
|
|
207
|
-
// registry chooses Fetch UTF-8 text decoding or bounded byte projection.
|
|
208
|
-
// This keeps handler-declared binary text/* types from being decoded first.
|
|
209
578
|
if (response.body === null)
|
|
210
|
-
return
|
|
579
|
+
return { ...common, body: "" };
|
|
211
580
|
const responseBody = response.body;
|
|
212
581
|
return {
|
|
582
|
+
...common,
|
|
213
583
|
body: {
|
|
214
584
|
chunks: responseBody,
|
|
215
585
|
text: () => response.text(),
|
|
216
586
|
cancel: () => responseBody.cancel(),
|
|
217
587
|
},
|
|
218
|
-
mimetype,
|
|
219
|
-
header,
|
|
220
588
|
};
|
|
221
589
|
}
|
|
222
|
-
static #header(response, requestHeaders) {
|
|
590
|
+
static #header(response, requestHeaders, addressedUrl) {
|
|
223
591
|
const responseHeaders = [...response.headers];
|
|
224
592
|
return [
|
|
225
593
|
`HTTP ${response.status} ${response.statusText}`,
|
|
226
594
|
...responseHeaders.map(([key, value]) => `${key}: ${value}`),
|
|
227
595
|
"x-plurnk-request-method: GET",
|
|
228
596
|
`x-plurnk-fetched-at: ${new Date().toISOString()}`,
|
|
597
|
+
`x-plurnk-response-url: ${safeEvidence(response.url || addressedUrl)}`,
|
|
229
598
|
cacheVariantEvidence(classifyCacheVariant(requestHeaders, responseHeaders)),
|
|
230
599
|
].join("\n");
|
|
231
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
|
+
}
|
|
232
609
|
}
|
|
233
610
|
//# sourceMappingURL=WebFetcher.js.map
|