@plurnk/plurnk-schemes-http 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Http.js CHANGED
@@ -3,12 +3,12 @@
3
3
  // {§http-lifecycle}. The implementation depends only on SchemeCtx capabilities.
4
4
  var _a;
5
5
  import { createParser } from "eventsource-parser";
6
- import { MimetypeClassifier, NetworkAddress, PathSyntax, ProjectionInputLimitError, Results, } from "@plurnk/plurnk-schemes";
6
+ import { MimetypeClassifier, NetworkAddress, ProjectionInputLimitError, Results, } from "@plurnk/plurnk-schemes";
7
7
  import { readFile } from "node:fs/promises";
8
- import Browser, { BROWSER_UA, requireNumEnv } from "./Browser.js";
9
8
  import ErrorDetail from "./ErrorDetail.js";
10
- import WebFetcher, { CACHE_VARIANT_HEADER, PROJECTION_ID_HEADER, cacheVariantEvidence, classifyCacheVariant, rewriteAcquisitionTarget, WebMaterializationError, } from "./WebFetcher.js";
9
+ import WebFetcher, { DEFAULT_WEB_UA, CACHE_VARIANT_HEADER, MATERIALIZER_ID_HEADER, PROJECTION_ID_HEADER, TAVILY_CREDITS_HEADER, TAVILY_REQUEST_ID_HEADER, cacheVariantEvidence, classifyCacheVariant, WebMaterializationError, } from "./WebFetcher.js";
11
10
  import { responseMimetype } from "./ContentType.js";
11
+ import { requireNonNegativeIntegerEnv as requireNumEnv } from "./Config.js";
12
12
  // The channel the response body streams into, and the header metadata channel.
13
13
  const BODY = "body";
14
14
  const HEADER = "header";
@@ -156,74 +156,79 @@ class Http {
156
156
  requiresWeb: true, // excluded under the loop's noWeb flag
157
157
  },
158
158
  };
159
- // The render foundation (lazy chromium). Injectable for tests; one warm
160
- // pool per Http instance, shared across this scheme's fetches.
161
- #browser;
162
159
  #errorDetailLimit;
163
160
  #webFetcher;
164
- constructor(browser = new Browser()) {
165
- this.#browser = browser;
161
+ constructor() {
166
162
  this.#errorDetailLimit = ErrorDetail.configuredLimit();
167
- this.#webFetcher = new WebFetcher(browser);
163
+ this.#webFetcher = new WebFetcher();
168
164
  }
169
165
  async ready() {
170
- const mode = await this.#browser.ready?.();
171
- if (mode !== undefined)
172
- console.info(`http renderer: ${mode}`);
173
- }
174
- async close() {
175
- await this.#webFetcher.close();
176
- }
177
- // FIND itself is the consumer's standard entry query. This hook owns only
178
- // HTTP's prerequisite: an exact URL that is not already an entry must be
179
- // acquired through the same checked byte and lazy-render path used by search
180
- // prefetch. Glob targets only query already-known web entries.
181
- async prepareFind(statement, ctx) {
182
- const target = statement.target;
183
- if (target === null || target.kind !== "url")
184
- return { shape: "passthrough", status: 200 };
166
+ WebFetcher.validateConfiguration();
167
+ }
168
+ // Representation preparation is scope-blind. Core applies the authored READ
169
+ // coordinates only after this hook has either materialized a finite GET or
170
+ // returned 102 for a retained SSE response.
171
+ async prepareRepresentation(request, ctx) {
172
+ if (request.target.kind !== "url") {
173
+ return _a.#bad(400, "http", "bad-target", "READ requires an http(s):// URL target.", {
174
+ stage: "target-validation",
175
+ recovery: "Provide an http(s):// URL target.",
176
+ retryable: false,
177
+ });
178
+ }
179
+ return this.#prepareGet(request.target, request.pathname, ctx);
180
+ }
181
+ async #prepareGet(target, pathname, ctx) {
185
182
  const address = _a.#address(target);
186
183
  if (!(address instanceof NetworkAddress))
187
184
  return address;
188
- const { pathname, url } = address;
185
+ const { url } = address;
189
186
  const requestHeaders = target.headers ?? [];
190
- if (PathSyntax.hasGlob(target.pathname))
191
- return { shape: "passthrough", status: 200 };
187
+ let cached;
188
+ const conditional = [];
192
189
  const prior = await ctx.entries.read(pathname);
193
- if (prior.entry !== null) {
194
- const priorHeader = prior.entry.channels[HEADER]?.content ?? "";
195
- const priorMethod = _a.#requestMethod(priorHeader);
196
- if (priorMethod === undefined) {
197
- return { shape: "passthrough", status: 200 };
198
- }
199
- let reusable;
190
+ if (Results.isErrorStatus(prior.status) && prior.status !== 404) {
191
+ return _a.#passthrough(prior);
192
+ }
193
+ const priorBody = prior.entry?.channels[BODY];
194
+ const priorHeader = prior.entry?.channels[HEADER];
195
+ if (prior.entry !== null && _a.#requestMethod(priorHeader?.content ?? "") === undefined) {
196
+ return { status: 200 };
197
+ }
198
+ if (prior.entry !== null && priorBody !== undefined && priorHeader !== undefined) {
200
199
  try {
201
- reusable = await _a.#reusableGetRepresentation(prior.entry, requestHeaders, ctx.projection);
200
+ if (await _a.#reusableGetRepresentation(prior.entry, requestHeaders, ctx.projection)) {
201
+ cached = prior.entry;
202
+ if (_a.#materializerIdentity(priorHeader.content) === undefined) {
203
+ conditional.push(..._a.#validators(priorHeader.content));
204
+ }
205
+ }
202
206
  }
203
207
  catch (cause) {
204
- return _a.#materializationFailure(url, "GET", new WebMaterializationError("projection", _a.#sourceMimetype(priorHeader), cause));
205
- }
206
- if (reusable && _a.#fresh(priorHeader)) {
207
- return { shape: "passthrough", status: 200 };
208
+ return _a.#materializationFailure(url, "GET", new WebMaterializationError(_a.#sourceMimetype(priorHeader.content), cause));
208
209
  }
209
210
  }
210
- if (Results.isErrorStatus(prior.status) && prior.status !== 404) {
211
- return _a.#passthrough(prior);
211
+ if (cached !== undefined && _a.#fresh(cached.channels[HEADER].content)) {
212
+ return { status: 200 };
212
213
  }
213
- // {§prefetch} WebFetcher preserves caller cancellation as rejection;
214
- // this operation owns its one model-facing 499 projection.
215
214
  let fetched;
216
215
  try {
217
216
  fetched = await this.#webFetcher.fetch(url, {
218
217
  signal: ctx.signal,
219
218
  headers: requestHeaders,
219
+ ...(conditional.length > 0
220
+ ? { conditionalHeaders: conditional }
221
+ : {}),
222
+ guarded: false,
223
+ acceptHttpErrors: true,
224
+ preserveUnavailable: true,
220
225
  });
221
226
  }
222
- catch (err) {
223
- if (ctx.signal?.aborted === true && err === ctx.signal.reason) {
227
+ catch (error) {
228
+ if (ctx.signal?.aborted === true && error === ctx.signal.reason) {
224
229
  return _a.#cancelled(url, "GET");
225
230
  }
226
- throw err;
231
+ throw error;
227
232
  }
228
233
  if (fetched === null) {
229
234
  return _a.#bad(404, "http", "not-materialized", `The URL ${url} could not be materialized.`, {
@@ -232,48 +237,111 @@ class Http {
232
237
  retryable: true,
233
238
  });
234
239
  }
240
+ if (fetched.status === 304) {
241
+ if (cached === undefined) {
242
+ return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 without a reusable stored representation.`, {
243
+ target: url,
244
+ method: "GET",
245
+ stage: "acquisition",
246
+ retryable: true,
247
+ });
248
+ }
249
+ const cachedHeader = cached.channels[HEADER].content;
250
+ if (_a.#materializerIdentity(cachedHeader) !== undefined) {
251
+ return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 for a stored representation that requires full reacquisition.`, {
252
+ target: url,
253
+ method: "GET",
254
+ stage: "acquisition",
255
+ retryable: true,
256
+ });
257
+ }
258
+ const responseHeaders = fetched.responseHeaders ?? [];
259
+ if (!_a.#revalidationCorresponds(cachedHeader, new Headers(responseHeaders.map(([name, value]) => [name, value])))) {
260
+ return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 without identifying the stored representation nominated for revalidation.`, {
261
+ target: url,
262
+ method: "GET",
263
+ stage: "acquisition",
264
+ retryable: true,
265
+ });
266
+ }
267
+ const channels = {
268
+ ...cached.channels,
269
+ [HEADER]: {
270
+ ...cached.channels[HEADER],
271
+ content: _a.#refreshAfter304(cachedHeader, responseHeaders, requestHeaders),
272
+ },
273
+ };
274
+ const written = await ctx.entries.write(pathname, {
275
+ channels,
276
+ tags: cached.tags,
277
+ });
278
+ if (Results.isErrorStatus(written.status))
279
+ return _a.#passthrough(written);
280
+ return { status: 200 };
281
+ }
282
+ if (fetched.mimetype === "text/event-stream"
283
+ && fetched.response?.body !== null
284
+ && fetched.response?.body !== undefined) {
285
+ return this.#openEventStream(address, fetched, ctx);
286
+ }
235
287
  let materialized;
236
288
  try {
237
- materialized = await WebFetcher.materialize(fetched, ctx.projection);
289
+ materialized = await WebFetcher.materialize(fetched, ctx.projection, ctx.signal);
238
290
  }
239
- catch (err) {
291
+ catch (error) {
240
292
  if (ctx.signal?.aborted === true)
241
293
  return _a.#cancelled(url, "GET");
242
- if (err instanceof WebMaterializationError) {
243
- return _a.#materializationFailure(url, "GET", err);
294
+ if (error instanceof WebMaterializationError) {
295
+ return _a.#materializationFailure(url, "GET", error);
244
296
  }
245
- throw err;
246
- }
247
- if (materialized === null)
248
- return _a.#noReadableProjection(url);
249
- const channels = {
250
- [BODY]: materialized.body,
251
- ...(materialized.html === undefined ? {} : { html: materialized.html }),
252
- ...(materialized.header === undefined
253
- ? {}
254
- : { [HEADER]: { content: materialized.header, mimetype: "text/plain" } }),
255
- };
256
- const written = await ctx.entries.write(pathname, { channels, tags: [] });
257
- return _a.#passthrough(written);
258
- }
259
- // An unscoped READ fetches/revalidates. A scoped READ observes its selected
260
- // already-materialized channel through universal READ: refetching would
261
- // discard the requested range and return another asynchronous whole-body 102.
262
- async read(statement, ctx) {
263
- if (statement.target === null || statement.target.kind !== "url") {
264
- return _a.#bad(400, "http", "bad-target", "READ requires an http(s):// URL target.", {
265
- stage: "target-validation",
266
- recovery: "Provide an http(s):// URL target.",
297
+ throw error;
298
+ }
299
+ if (materialized === null) {
300
+ return _a.#bad(415, "http", "binary-response-unsupported", `HTTP GET ${url} returned ${fetched.mimetype}. The remote response was received, but its binary body cannot be represented in a Plurnk text channel.`, {
301
+ target: url,
302
+ method: "GET",
303
+ mimetype: fetched.mimetype,
304
+ stage: "materialization",
305
+ recovery: "Inspect #header or use a byte-capable client.",
267
306
  retryable: false,
268
307
  });
269
308
  }
270
- if (statement.lineMarker !== null) {
271
- const address = _a.#address(statement.target);
272
- if (!(address instanceof NetworkAddress))
273
- return address;
274
- return _a.#passthrough(await ctx.entries.operations.read(statement));
309
+ const written = await ctx.entries.write(pathname, {
310
+ channels: WebFetcher.materializedChannels(materialized, { url, method: "GET" }),
311
+ tags: [],
312
+ });
313
+ if (Results.isErrorStatus(written.status))
314
+ return _a.#passthrough(written);
315
+ return { status: 200 };
316
+ }
317
+ async #openEventStream(address, fetched, ctx) {
318
+ const response = fetched.response;
319
+ if (response?.body === null || response?.body === undefined) {
320
+ throw new Error("SSE preparation lost its response body.");
275
321
  }
276
- return this.#fetchStream(statement.target, ctx, "GET", undefined);
322
+ const local = new AbortController();
323
+ const handle = {
324
+ cancel: async () => {
325
+ local.abort();
326
+ await response.body?.cancel().catch(() => { });
327
+ },
328
+ };
329
+ const written = await ctx.entries.write(address.pathname, _a.#seedEntry());
330
+ if (Results.isErrorStatus(written.status))
331
+ return _a.#passthrough(written);
332
+ const subscription = await ctx.subscriptions.open(address.pathname, handle);
333
+ if (fetched.header !== undefined) {
334
+ await subscription.notifyChunk(HEADER, fetched.header, "text/plain");
335
+ }
336
+ void _a.#settleEventStream(subscription, response, {
337
+ url: address.url,
338
+ method: "GET",
339
+ signal: local.signal,
340
+ errorDetailLimit: this.#errorDetailLimit,
341
+ }).catch((error) => {
342
+ console.error("HTTP SSE terminal cleanup failed", { url: address.url, error });
343
+ });
344
+ return { shape: "passthrough", status: 102 };
277
345
  }
278
346
  // EDIT -> PUT the body (full-resource replace). `<L>` has no meaning against a
279
347
  // remote resource - reject rather than silently ignore the model's intent.
@@ -301,7 +369,7 @@ class Http {
301
369
  retryable: false,
302
370
  });
303
371
  }
304
- return this.#fetchStream(statement.target, ctx, "PUT", statement.body ?? "");
372
+ return this.#request(statement.target, ctx, "PUT", statement.body ?? "");
305
373
  }
306
374
  async edit(statement, ctx) {
307
375
  return this.editBatch([statement], ctx);
@@ -316,7 +384,7 @@ class Http {
316
384
  retryable: false,
317
385
  });
318
386
  }
319
- return this.#fetchStream(statement.target, ctx, "DELETE", statement.body ?? undefined);
387
+ return this.#request(statement.target, ctx, "DELETE", statement.body ?? undefined);
320
388
  }
321
389
  // SEND dispatch — status-code-as-verb (SPEC {§op-surface}).
322
390
  // 200 -> request with body (POST), stream response
@@ -335,7 +403,7 @@ class Http {
335
403
  const status = statement.signal;
336
404
  if (status === 200) {
337
405
  const body = statement.body?.raw ?? "";
338
- return this.#fetchStream(statement.target, ctx, "POST", body);
406
+ return this.#request(statement.target, ctx, "POST", body);
339
407
  }
340
408
  if (status === 410) {
341
409
  const address = _a.#address(statement.target);
@@ -345,7 +413,7 @@ class Http {
345
413
  }
346
414
  if (status === 499) {
347
415
  // Cancellation is routed by the engine to the subscription's
348
- // SubscriptionHandle.cancel (registered in #fetchStream). Nothing
416
+ // SubscriptionHandle.cancel (registered by #request). Nothing
349
417
  // for the scheme to do at the op level.
350
418
  return { shape: "passthrough", status: 200 };
351
419
  }
@@ -356,13 +424,14 @@ class Http {
356
424
  retryable: false,
357
425
  });
358
426
  }
359
- // {§http-lifecycle} One direct-operation path owns seeding,
360
- // subscription cancellation, response materialization, and settlement.
361
- async #fetchStream(target, ctx, method, body) {
427
+ // Mutation responses use the same entry/channel and subscription primitives
428
+ // as every live producer; GET acquisition belongs solely to
429
+ // prepareRepresentation.
430
+ async #request(target, ctx, method, body) {
362
431
  const address = _a.#address(target);
363
432
  if (!(address instanceof NetworkAddress))
364
433
  return address;
365
- const url = method === "GET" ? rewriteAcquisitionTarget(address.url) : address.url;
434
+ const { url } = address;
366
435
  const { pathname } = address;
367
436
  const headers = target.headers ?? [];
368
437
  const publishedChannel = target.fragment ?? _a.manifest.defaultChannel;
@@ -375,48 +444,6 @@ class Http {
375
444
  retryable: false,
376
445
  });
377
446
  }
378
- // {§revalidation} — recover only a GET-marked representation and its
379
- // validators before the seed write replaces the stored channels.
380
- let cached;
381
- const conditional = [];
382
- if (method === "GET") {
383
- const prior = await ctx.entries.read(pathname);
384
- if (Results.isErrorStatus(prior.status) && prior.status !== 404) {
385
- return _a.#passthrough(prior);
386
- }
387
- const pb = prior.entry?.channels[BODY];
388
- const ph = prior.entry?.channels[HEADER];
389
- if (prior.entry !== null && pb !== undefined && ph !== undefined) {
390
- try {
391
- if (await _a.#reusableGetRepresentation(prior.entry, headers, ctx.projection)) {
392
- const html = prior.entry.channels.html;
393
- cached = {
394
- header: ph.content,
395
- body: { content: pb.content, mimetype: pb.mimetype },
396
- ...(html === undefined || html.content.length === 0 ? {} : { html: { content: html.content, mimetype: html.mimetype } }),
397
- };
398
- conditional.push(..._a.#validators(ph.content));
399
- }
400
- }
401
- catch (cause) {
402
- return _a.#materializationFailure(url, method, new WebMaterializationError("projection", _a.#sourceMimetype(ph.content), cause));
403
- }
404
- }
405
- }
406
- // {§revalidation} TTL fast path. Serving does not refresh the stamp;
407
- // only a network acquisition or 304 vouches for a new acquisition time.
408
- if (cached !== undefined && _a.#fresh(cached.header)) {
409
- const written = await ctx.entries.write(pathname, _a.#seedEntry());
410
- if (Results.isErrorStatus(written.status))
411
- return _a.#passthrough(written);
412
- const subscription = await ctx.subscriptions.open(pathname, { cancel: () => { } }, { publishedChannel });
413
- await subscription.notifyChunk(HEADER, cached.header, "text/plain");
414
- if (cached.html !== undefined)
415
- await subscription.notifyChunk("html", cached.html.content, cached.html.mimetype);
416
- await subscription.notifyChunk(BODY, cached.body.content, cached.body.mimetype);
417
- await subscription.close({ status: 200 }, `ttl-fresh; ${cached.body.content.length} chars from cache`);
418
- return { shape: "passthrough", status: 102 };
419
- }
420
447
  // Local AbortController for force-cancel from outside (SEND[499]).
421
448
  const local = new AbortController();
422
449
  const handle = { cancel: () => local.abort() };
@@ -426,94 +453,21 @@ class Http {
426
453
  if (Results.isErrorStatus(written.status))
427
454
  return _a.#passthrough(written);
428
455
  // open() returns the worker+teardown-composed signal — fires on loop.cancel
429
- // OR our local teardown. Wire it so either path aborts the fetch/render.
430
- const subscription = await ctx.subscriptions.open(pathname, handle, { publishedChannel });
456
+ // OR our local teardown. Wire it so either path aborts acquisition.
457
+ const subscription = await ctx.subscriptions.open(pathname, handle);
431
458
  const onAbort = () => local.abort();
432
459
  subscription.addEventListener("abort", onAbort, { once: true });
433
- let detached = false;
434
460
  try {
435
461
  const response = await fetch(url, {
436
462
  method,
437
463
  body,
438
- // One browser identity on the wire (never Node's default "node"
439
- // UA — a loud automated-client fingerprint). The model's own
440
- // {User-Agent: …} block wins when present.
441
464
  headers: headers.some(([k]) => k.toLowerCase() === "user-agent")
442
- ? [...headers, ...conditional]
443
- : [["User-Agent", BROWSER_UA], ...headers, ...conditional],
465
+ ? headers
466
+ : [["User-Agent", DEFAULT_WEB_UA], ...headers],
444
467
  signal: local.signal,
445
468
  redirect: "follow",
446
469
  });
447
- // {§revalidation} A corresponding 304 restores the nominated GET
448
- // representation into the freshly seeded channels and remains an
449
- // ordinary streaming READ.
450
- if (cached !== undefined && response.status === 304) {
451
- if (!_a.#revalidationCorresponds(cached.header, response.headers)) {
452
- throw new Error("HTTP 304 did not identify the stored representation nominated for revalidation.");
453
- }
454
- await subscription.notifyChunk(HEADER, _a.#refreshAfter304(cached.header, [...response.headers], headers), "text/plain");
455
- if (cached.html !== undefined)
456
- await subscription.notifyChunk("html", cached.html.content, cached.html.mimetype);
457
- await subscription.notifyChunk(BODY, cached.body.content, cached.body.mimetype);
458
- await subscription.close({ status: 200 }, `revalidated 304; ${cached.body.content.length} chars from cache`);
459
- return { shape: "passthrough", status: 102 };
460
- }
461
470
  const responseMime = responseMimetype(response.headers.get("content-type"));
462
- // Always-render: a GET of an HTML page is re-acquired through the
463
- // browser so the body is projected from the final rendered DOM. The probe-fetch
464
- // body is discarded — the browser does its own navigation. Only GET
465
- // renders; POST/PUT/DELETE can't be replayed as a browser navigation.
466
- const isHtml = method === "GET" && MimetypeClassifier.isHtml(responseMime);
467
- if (isHtml) {
468
- await response.body?.cancel();
469
- let result;
470
- try {
471
- result = await this.#browser.render(url, {
472
- workerId: ctx.workerId,
473
- signal: local.signal,
474
- headers,
475
- });
476
- }
477
- catch (cause) {
478
- throw new WebMaterializationError("render", responseMime, cause);
479
- }
480
- await _a.#writeHeader(subscription, method, result.status, result.statusText, result.headers, headers);
481
- await subscription.notifyChunk("html", result.html, "text/html");
482
- const materialized = await WebFetcher.materialize({ body: result.html, mimetype: "text/html" }, ctx.projection);
483
- if (materialized === null) {
484
- const failure = _a.#noReadableProjection(url);
485
- await subscription.close(failure, failure.problem?.detail);
486
- return failure;
487
- }
488
- if (materialized.projection !== undefined) {
489
- await _a.#writeProjectionIdentity(subscription, materialized.projection.identity);
490
- }
491
- await subscription.notifyChunk(BODY, materialized.body.content, materialized.body.mimetype);
492
- await subscription.close({ status: 200 }, `rendered HTTP ${result.status}; ${materialized.body.content.length} readable chars`);
493
- return { shape: "passthrough", status: 102 };
494
- }
495
- // SSE: an event stream is parsed into its `data` payloads (SPEC {§sse}),
496
- // one notifyChunk per event with the `data:`/comment framing stripped,
497
- // so the model reads event content and not the transport. A long-lived
498
- // GET; events land in the body channel as they arrive across turns,
499
- // until the origin closes. Only GET — a POST reply is never an SSE READ.
500
- if (method === "GET" && responseMime === "text/event-stream") {
501
- await _a.#writeHeader(subscription, method, response.status, response.statusText, [...response.headers], headers);
502
- if (response.body === null) {
503
- await subscription.close({ status: 200 }, "SSE stream; empty body");
504
- return { shape: "passthrough", status: 102 };
505
- }
506
- detached = true;
507
- void _a.#settleEventStream(subscription, response, {
508
- url,
509
- method,
510
- signal: local.signal,
511
- errorDetailLimit: this.#errorDetailLimit,
512
- }).catch((error) => {
513
- console.error("HTTP SSE terminal cleanup failed", { url, error });
514
- }).finally(() => subscription.removeEventListener("abort", onAbort));
515
- return { shape: "passthrough", status: 102 };
516
- }
517
471
  // {§http-lifecycle}/{§mimetype-classifier} String channels retain
518
472
  // textual response data. Binary input is transient: an installed
519
473
  // reader may derive Unicode, otherwise the durable body is a typed
@@ -599,8 +553,7 @@ class Http {
599
553
  return result;
600
554
  }
601
555
  finally {
602
- if (!detached)
603
- subscription.removeEventListener("abort", onAbort);
556
+ subscription.removeEventListener("abort", onAbort);
604
557
  }
605
558
  }
606
559
  // {§http-manifest}/{§http-lifecycle} Seed the declared channel shape before
@@ -613,11 +566,16 @@ class Http {
613
566
  }]));
614
567
  return { channels, tags: [] };
615
568
  }
616
- // {§revalidation} `static` exact materializations and successfully `closed`
617
- // streams are final. Active or failed channels cannot vouch for a reusable
618
- // representation, regardless of whether partial content is non-empty.
569
+ // {§revalidation} The body and header must have settled successfully. An
570
+ // auxiliary channel may have terminally failed without invalidating body.
619
571
  static #representationComplete(entry) {
620
- return Object.values(entry.channels).every(({ state }) => state === "static" || state === "closed");
572
+ const bodyState = entry.channels[BODY]?.state;
573
+ const headerState = entry.channels[HEADER]?.state;
574
+ const successful = (state) => state === "static" || state === "closed";
575
+ const terminal = (state) => successful(state) || state === "errored";
576
+ return successful(bodyState)
577
+ && successful(headerState)
578
+ && Object.values(entry.channels).every(({ state }) => terminal(state));
621
579
  }
622
580
  // {§revalidation} Direct READ and exact FIND share the complete reusable
623
581
  // representation predicate; freshness only decides how READ uses a match.
@@ -636,13 +594,16 @@ class Http {
636
594
  // {§revalidation} Origin headers come first; authoritative package method
637
595
  // and acquisition metadata are appended last.
638
596
  static async #writeHeader(subscription, method, status, statusText, responseHeaders, requestHeaders) {
597
+ await subscription.notifyChunk(HEADER, _a.#responseHeader(method, status, statusText, responseHeaders, requestHeaders), "text/plain");
598
+ }
599
+ static #responseHeader(method, status, statusText, responseHeaders, requestHeaders) {
639
600
  const lines = [`HTTP ${status} ${statusText}`];
640
601
  for (const [k, v] of responseHeaders)
641
602
  lines.push(`${k}: ${v}`);
642
603
  lines.push(`${REQUEST_METHOD}: ${method}`);
643
604
  lines.push(`${FETCHED_AT}: ${new Date().toISOString()}`);
644
605
  lines.push(cacheVariantEvidence(classifyCacheVariant(requestHeaders, responseHeaders)));
645
- await subscription.notifyChunk(HEADER, lines.join("\n"), "text/plain");
606
+ return lines.join("\n");
646
607
  }
647
608
  static async #writeProjectionIdentity(subscription, identity) {
648
609
  await subscription.notifyChunk(HEADER, `\n${WebFetcher.projectionEvidence(identity)}`, "text/plain");
@@ -652,6 +613,11 @@ class Http {
652
613
  static async #settleEventStream(subscription, response, options) {
653
614
  try {
654
615
  const events = await _a.#streamEvents(subscription, response);
616
+ if (options.signal.aborted) {
617
+ const result = _a.#cancelled(options.url, options.method);
618
+ await subscription.close(result, result.problem?.detail);
619
+ return;
620
+ }
655
621
  await subscription.close({ status: 200 }, `SSE stream; ${events} events`);
656
622
  }
657
623
  catch (error) {
@@ -873,6 +839,9 @@ class Http {
873
839
  static #projectionIdentity(priorHeader) {
874
840
  return _a.#packageHeaderValue(priorHeader, PROJECTION_ID_HEADER);
875
841
  }
842
+ static #materializerIdentity(priorHeader) {
843
+ return _a.#packageHeaderValue(priorHeader, MATERIALIZER_ID_HEADER);
844
+ }
876
845
  static #cacheVariant(priorHeader) {
877
846
  const value = _a.#packageHeaderValue(priorHeader, CACHE_VARIANT_HEADER);
878
847
  return value === "default" || value === "bypass" ? value : undefined;
@@ -881,10 +850,22 @@ class Http {
881
850
  return responseMimetype(lastHeaderValue(header, "content-type") ?? null);
882
851
  }
883
852
  static async #projectionCurrent(header, projection) {
853
+ const sourceMimetype = _a.#sourceMimetype(header);
854
+ const isHtml = MimetypeClassifier.isHtml(sourceMimetype);
855
+ const materializerIdentity = _a.#materializerIdentity(header);
856
+ if (materializerIdentity !== undefined) {
857
+ if (!WebFetcher.materializerCurrent(materializerIdentity))
858
+ return false;
859
+ if (materializerIdentity.startsWith("tavily-extract:")
860
+ || materializerIdentity === "origin-markdown:v1")
861
+ return true;
862
+ }
863
+ if (isHtml && materializerIdentity === undefined)
864
+ return false;
884
865
  const storedIdentity = _a.#projectionIdentity(header);
885
866
  if (storedIdentity === undefined)
886
- return true;
887
- return await projection.identity(_a.#sourceMimetype(header)) === storedIdentity;
867
+ return !isHtml;
868
+ return await projection.identity(sourceMimetype) === storedIdentity;
888
869
  }
889
870
  // RFC 9111 §3.2 permits a processed cache to retain the metadata that its
890
871
  // stored body depends upon. Other 304 fields update by name; package-owned
@@ -899,7 +880,15 @@ class Http {
899
880
  ...retained.map(({ name, value }) => [name, value]),
900
881
  ...updates,
901
882
  ];
902
- const projectionIdentity = _a.#projectionIdentity(header);
883
+ const packageEvidence = [
884
+ MATERIALIZER_ID_HEADER,
885
+ PROJECTION_ID_HEADER,
886
+ TAVILY_REQUEST_ID_HEADER,
887
+ TAVILY_CREDITS_HEADER,
888
+ ].flatMap((field) => {
889
+ const value = _a.#packageHeaderValue(header, field);
890
+ return value === undefined ? [] : [`${field}: ${value}`];
891
+ });
903
892
  return [
904
893
  lines[0] ?? "HTTP 200 OK",
905
894
  ...retained.map(({ line }) => line),
@@ -907,9 +896,7 @@ class Http {
907
896
  `${REQUEST_METHOD}: GET`,
908
897
  `${FETCHED_AT}: ${new Date().toISOString()}`,
909
898
  cacheVariantEvidence(classifyCacheVariant(requestHeaders, merged)),
910
- ...(projectionIdentity === undefined
911
- ? []
912
- : [WebFetcher.projectionEvidence(projectionIdentity)]),
899
+ ...packageEvidence,
913
900
  ].join("\n");
914
901
  }
915
902
  static #revalidationCorresponds(priorHeader, responseHeaders) {
@@ -979,20 +966,10 @@ class Http {
979
966
  });
980
967
  }
981
968
  console.error("HTTP materialization failed", { method, url, error });
982
- const projection = error.stage === "projection";
983
- return _a.#bad(projection ? 500 : 502, "http", projection ? "projection-failed" : "render-failed", projection
984
- ? `HTTP ${method} ${url} acquired content, but its readable projection failed.`
985
- : `HTTP ${method} ${url} could not render HTML.`, {
969
+ return _a.#bad(500, "http", "projection-failed", `HTTP ${method} ${url} acquired content, but its readable projection failed.`, {
986
970
  target: url,
987
971
  method,
988
972
  mimetype: error.mimetype,
989
- stage: error.stage,
990
- retryable: !projection,
991
- });
992
- }
993
- static #noReadableProjection(url) {
994
- return _a.#bad(422, "http", "no-readable-projection", `The URL ${url} has no readable projection.`, {
995
- target: url,
996
973
  stage: "projection",
997
974
  retryable: false,
998
975
  });