@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.
@@ -1,90 +1,357 @@
1
- // Checked entry-acquisition primitive {§prefetch}. The byte response is primary;
2
- // HTML carries a lazy browser fallback for the consumer to invoke only when its
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(stage, mimetype, cause) {
30
- super(`Web ${stage} failed for ${mimetype}.`, { cause });
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
- // One warm-Chromium pool shared across prefetches (render context keyed 0 —
38
- // prefetch is public-page acquisition, no per-worker cookie isolation to keep).
39
- #browser;
40
- constructor(browser = new Browser()) {
41
- this.#browser = browser;
42
- }
43
- // {§html-materialization} One projection seam for exact HTTP preparation
44
- // and executor entry acquisition. A present empty projection is valid;
45
- // only null asks the lazy renderer or reports final absence. Projection
46
- // provenance travels with the body; failures retain their exact cause.
47
- static async materialize(fetched, projection) {
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, undefined, fetched.header);
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 content.length === 0
58
- ? null
59
- : {
60
- body: { content, mimetype: fetched.mimetype },
61
- ...(fetched.header === undefined ? {} : { header: fetched.header }),
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
- let html = { content: fetched.body, mimetype: fetched.mimetype };
71
- let projected = await WebFetcher.#project(html, projection);
72
- if (projected === null && fetched.render !== undefined) {
73
- let rendered;
74
- try {
75
- rendered = await fetched.render();
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
- catch (cause) {
78
- throw new WebMaterializationError("render", html.mimetype, cause);
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
- if (rendered !== null) {
81
- html = { content: rendered.body, mimetype: rendered.mimetype };
82
- projected = await WebFetcher.#project(html, projection);
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
- return projected === null
86
- ? null
87
- : WebFetcher.#materialized(projected, html, fetched.header);
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("projection", mimetype, cause);
376
+ throw new WebMaterializationError(mimetype, cause);
110
377
  }
111
378
  return projected;
112
379
  }
113
380
  static async #projectionFailure(body, mimetype, cause) {
114
- let failure = cause;
381
+ let failureCause = cause;
115
382
  try {
116
383
  await body.cancel();
117
384
  }
118
385
  catch (cleanupCause) {
119
- failure = new AggregateError([cause, cleanupCause], `Projection and response-body cleanup both failed for ${mimetype}.`);
386
+ failureCause = new AggregateError([cause, cleanupCause], `Projection and response-body cleanup both failed for ${mimetype}.`);
120
387
  }
121
- throw new WebMaterializationError("projection", mimetype, failure);
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("projection", html.mimetype, cause);
395
+ throw new WebMaterializationError(html.mimetype, cause);
129
396
  }
130
397
  }
131
- static #materialized(projected, html, header) {
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
- ...(header === undefined ? {} : {
136
- header: `${header}\n${WebFetcher.projectionEvidence(projected.projectionIdentity)}`,
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
- async close() {
148
- await this.#browser.close?.();
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 transportHeaders = requestHeaders.some(([name]) => name.toLowerCase() === "user-agent")
155
- ? requestHeaders
156
- : [["User-Agent", BROWSER_UA], ...requestHeaders];
157
- // Bound the byte probe independently. Browser.render owns its navigation
158
- // deadline so it can apply the substantive-DOM timeout salvage contract.
159
- const probeTimeout = AbortSignal.timeout(requireNumEnv("PLURNK_SCHEMES_HTTP_FETCH_TIMEOUT"));
160
- const probeSignal = opts?.signal ? AbortSignal.any([opts.signal, probeTimeout]) : probeTimeout;
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 Guard.fetch(target, {
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
- const mimetype = responseMimetype(response.headers.get("content-type"));
179
- if (!response.ok) {
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
- } // non-2xx dead
183
- const header = WebFetcher.#header(response, requestHeaders);
184
- // Preserve server HTML as primary. Eager rendering can mutate already
185
- // useful content; the consumer alone decides whether projection is absent.
186
- if (MimetypeClassifier.isHtml(mimetype)) {
187
- const body = await response.text();
188
- 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)
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
- body,
192
- mimetype,
193
- header,
194
- render: async () => {
195
- const rendered = await this.#browser.render(target, {
196
- workerId: 0,
197
- // Caller cancellation still spans the whole operation.
198
- // The renderer supplies its own per-navigation deadline.
199
- signal: opts?.signal,
200
- headers: transportHeaders,
201
- });
202
- return rendered.html.length > 0 ? { body: rendered.html, mimetype: "text/html" } : null;
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 null;
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