@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.
Files changed (47) hide show
  1. package/.env.defaults +18 -46
  2. package/README.md +74 -23
  3. package/SPEC.md +487 -124
  4. package/dist/Config.d.ts +3 -0
  5. package/dist/Config.d.ts.map +1 -0
  6. package/dist/Config.js +18 -0
  7. package/dist/Config.js.map +1 -0
  8. package/dist/ContentType.d.ts +2 -0
  9. package/dist/ContentType.d.ts.map +1 -0
  10. package/dist/ContentType.js +15 -0
  11. package/dist/ContentType.js.map +1 -0
  12. package/dist/ErrorDetail.d.ts +6 -0
  13. package/dist/ErrorDetail.d.ts.map +1 -0
  14. package/dist/ErrorDetail.js +19 -0
  15. package/dist/ErrorDetail.js.map +1 -0
  16. package/dist/Guard.d.ts +1 -1
  17. package/dist/Guard.d.ts.map +1 -1
  18. package/dist/Guard.js +95 -55
  19. package/dist/Guard.js.map +1 -1
  20. package/dist/Http.d.ts +3 -16
  21. package/dist/Http.d.ts.map +1 -1
  22. package/dist/Http.js +817 -331
  23. package/dist/Http.js.map +1 -1
  24. package/dist/TavilyExtract.d.ts +35 -0
  25. package/dist/TavilyExtract.d.ts.map +1 -0
  26. package/dist/TavilyExtract.js +204 -0
  27. package/dist/TavilyExtract.js.map +1 -0
  28. package/dist/WebFetcher.d.ts +88 -18
  29. package/dist/WebFetcher.d.ts.map +1 -1
  30. package/dist/WebFetcher.js +588 -70
  31. package/dist/WebFetcher.js.map +1 -1
  32. package/dist/Ws.d.ts +7 -3
  33. package/dist/Ws.d.ts.map +1 -1
  34. package/dist/Ws.js +432 -96
  35. package/dist/Ws.js.map +1 -1
  36. package/dist/index.d.ts +2 -3
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +4 -10
  39. package/dist/index.js.map +1 -1
  40. package/docs/http.md +108 -53
  41. package/docs/wss.md +39 -43
  42. package/package.json +14 -13
  43. package/dist/Browser.d.ts +0 -84
  44. package/dist/Browser.d.ts.map +0 -1
  45. package/dist/Browser.js +0 -273
  46. package/dist/Browser.js.map +0 -1
  47. package/scripts/install-browser.mjs +0 -19
@@ -1,92 +1,610 @@
1
- // The guarded acquisition primitive core's entrySink calls (#454). The byte
2
- // response is primary; HTML carries a lazy guarded browser fallback for core
3
- // to invoke only when MIME projection is empty. Dead-ness is a value, never a
4
- // throw — null covers SSRF-refused, unreachable, non-2xx, non-textual, and empty.
5
- import Browser, { BROWSER_UA, requireNumEnv } from "./Browser.js";
6
- import Guard from "./Guard.js";
7
- // Day-one textual set (owner ruling, #454): text/* plus the +json/+xml family.
8
- // Non-textual bodies (pdf, images) prune to null — they don't survive the
9
- // string entry() contract core prefetches into.
10
- const isTextual = (mimetype) => mimetype.startsWith("text/")
11
- || ["application/json", "application/xml", "application/xhtml+xml"].includes(mimetype)
12
- || mimetype.endsWith("+json") || mimetype.endsWith("+xml");
13
- const isHtml = (mimetype) => mimetype === "text/html" || mimetype === "application/xhtml+xml";
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
- // One warm-Chromium pool shared across prefetches (render context keyed 0 —
16
- // prefetch is public-page acquisition, no per-worker cookie isolation to keep).
17
- #browser;
18
- constructor(browser = new Browser()) {
19
- this.#browser = browser;
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
- async close() {
22
- await this.#browser.close?.();
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
- // Bound the byte probe independently. Browser.render owns its own
26
- // navigation timeout and must be allowed to observe Playwright's
27
- // TimeoutError so Browser.#safeGoto can salvage an already-rendered DOM.
28
- // Sharing this timeout with render used to close the page at the exact
29
- // same instant as goto timed out, converting salvageable news pages into
30
- // "Target closed" and returning null (#596).
31
- const probeTimeout = AbortSignal.timeout(requireNumEnv("PLURNK_SCHEMES_HTTP_FETCH_TIMEOUT"));
32
- const probeSignal = opts?.signal ? AbortSignal.any([opts.signal, probeTimeout]) : probeTimeout;
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 Guard.fetch(url, { method: "GET", body: undefined, headers: [["User-Agent", BROWSER_UA]] }, probeSignal);
471
+ response = await request(target, transportHeaders);
36
472
  }
37
- catch {
38
- return null; // SSRF-refused or unreachable — both dead
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
- const mimetype = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
41
- if (!response.ok) {
485
+ if (opts?.acceptHttpErrors !== true && !response.ok) {
42
486
  await response.body?.cancel();
43
487
  return null;
44
- } // non-2xx dead
45
- const header = WebFetcher.#header(response);
46
- // Preserve server-rendered HTML as the primary acquisition. The MIME
47
- // layer decides whether its model-facing projection is useful; only an
48
- // empty projection invokes the lazy browser fallback. Unconditionally
49
- // rendering valid SSR pages is slower and can mutate the DOM until
50
- // Readability selects unrelated feed/chrome content (#596).
51
- if (isHtml(mimetype)) {
52
- const body = await response.text();
53
- if (body.length === 0)
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
- body,
57
- mimetype,
58
- header,
59
- render: async () => {
60
- try {
61
- const rendered = await this.#browser.render(url, {
62
- workerId: 0,
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 (!isTextual(mimetype)) {
78
- await response.body?.cancel();
79
- return null;
80
- } // binary pruned
81
- const body = await response.text();
82
- return body.length > 0 ? { body, mimetype, header } : null; // empty is dead
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
- ...[...response.headers].map(([key, value]) => `${key}: ${value}`),
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