@plurnk/plurnk-schemes-http 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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";
@@ -150,80 +150,93 @@ class Http {
150
150
  volatile: true, // remote content can change between fetches
151
151
  modelVisible: true,
152
152
  glyph: "🌐",
153
- example: "<<READ(https://example.com/page)::READ",
153
+ example: [
154
+ "## READ0 (https://example.com/page)",
155
+ "",
156
+ "## EDIT0 (https://api.example.com/v1/pets/42{Content-Type: application/json})",
157
+ '{"name":"Mango","status":"available"}',
158
+ "",
159
+ "## SEND0 [200] (https://api.example.com/v1/pets{Content-Type: application/json})",
160
+ '{"name":"Mango","status":"available"}',
161
+ ].join("\n"),
154
162
  documentation,
155
163
  flags: {
156
164
  requiresWeb: true, // excluded under the loop's noWeb flag
157
165
  },
158
166
  };
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
167
  #errorDetailLimit;
163
168
  #webFetcher;
164
- constructor(browser = new Browser()) {
165
- this.#browser = browser;
169
+ constructor() {
166
170
  this.#errorDetailLimit = ErrorDetail.configuredLimit();
167
- this.#webFetcher = new WebFetcher(browser);
171
+ this.#webFetcher = new WebFetcher();
168
172
  }
169
173
  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 };
174
+ WebFetcher.validateConfiguration();
175
+ }
176
+ // Representation preparation is scope-blind. Core applies the authored READ
177
+ // coordinates only after this hook has either materialized a finite GET or
178
+ // returned 102 for a retained SSE response.
179
+ async prepareRepresentation(request, ctx) {
180
+ if (request.target.kind !== "url") {
181
+ return _a.#bad(400, "http", "bad-target", "READ requires an http(s):// URL target.", {
182
+ stage: "target-validation",
183
+ recovery: "Provide an http(s):// URL target.",
184
+ retryable: false,
185
+ });
186
+ }
187
+ return this.#prepareGet(request.target, request.pathname, ctx);
188
+ }
189
+ async #prepareGet(target, pathname, ctx) {
185
190
  const address = _a.#address(target);
186
191
  if (!(address instanceof NetworkAddress))
187
192
  return address;
188
- const { pathname, url } = address;
193
+ const { url } = address;
189
194
  const requestHeaders = target.headers ?? [];
190
- if (PathSyntax.hasGlob(target.pathname))
191
- return { shape: "passthrough", status: 200 };
195
+ let cached;
196
+ const conditional = [];
192
197
  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;
198
+ if (Results.isErrorStatus(prior.status) && prior.status !== 404) {
199
+ return _a.#passthrough(prior);
200
+ }
201
+ const priorBody = prior.entry?.channels[BODY];
202
+ const priorHeader = prior.entry?.channels[HEADER];
203
+ if (prior.entry !== null && _a.#requestMethod(priorHeader?.content ?? "") === undefined) {
204
+ return { status: 200 };
205
+ }
206
+ if (prior.entry !== null && priorBody !== undefined && priorHeader !== undefined) {
200
207
  try {
201
- reusable = await _a.#reusableGetRepresentation(prior.entry, requestHeaders, ctx.projection);
208
+ if (await _a.#reusableGetRepresentation(prior.entry, requestHeaders, ctx.projection)) {
209
+ cached = prior.entry;
210
+ if (_a.#materializerIdentity(priorHeader.content) === undefined) {
211
+ conditional.push(..._a.#validators(priorHeader.content));
212
+ }
213
+ }
202
214
  }
203
215
  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 };
216
+ return _a.#materializationFailure(url, "GET", new WebMaterializationError(_a.#sourceMimetype(priorHeader.content), cause));
208
217
  }
209
218
  }
210
- if (Results.isErrorStatus(prior.status) && prior.status !== 404) {
211
- return _a.#passthrough(prior);
219
+ if (cached !== undefined && _a.#fresh(cached.channels[HEADER].content)) {
220
+ return { status: 200 };
212
221
  }
213
- // {§prefetch} WebFetcher preserves caller cancellation as rejection;
214
- // this operation owns its one model-facing 499 projection.
215
222
  let fetched;
216
223
  try {
217
224
  fetched = await this.#webFetcher.fetch(url, {
218
225
  signal: ctx.signal,
219
226
  headers: requestHeaders,
227
+ ...(conditional.length > 0
228
+ ? { conditionalHeaders: conditional }
229
+ : {}),
230
+ guarded: false,
231
+ acceptHttpErrors: true,
232
+ preserveUnavailable: true,
220
233
  });
221
234
  }
222
- catch (err) {
223
- if (ctx.signal?.aborted === true && err === ctx.signal.reason) {
235
+ catch (error) {
236
+ if (ctx.signal?.aborted === true && error === ctx.signal.reason) {
224
237
  return _a.#cancelled(url, "GET");
225
238
  }
226
- throw err;
239
+ throw error;
227
240
  }
228
241
  if (fetched === null) {
229
242
  return _a.#bad(404, "http", "not-materialized", `The URL ${url} could not be materialized.`, {
@@ -232,48 +245,109 @@ class Http {
232
245
  retryable: true,
233
246
  });
234
247
  }
248
+ if (fetched.status === 304) {
249
+ if (cached === undefined) {
250
+ return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 without a reusable stored representation.`, {
251
+ target: url,
252
+ method: "GET",
253
+ stage: "acquisition",
254
+ retryable: true,
255
+ });
256
+ }
257
+ const cachedHeader = cached.channels[HEADER].content;
258
+ if (_a.#materializerIdentity(cachedHeader) !== undefined) {
259
+ return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 for a stored representation that requires full reacquisition.`, {
260
+ target: url,
261
+ method: "GET",
262
+ stage: "acquisition",
263
+ retryable: true,
264
+ });
265
+ }
266
+ const responseHeaders = fetched.responseHeaders ?? [];
267
+ if (!_a.#revalidationCorresponds(cachedHeader, new Headers(responseHeaders.map(([name, value]) => [name, value])))) {
268
+ return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 without identifying the stored representation nominated for revalidation.`, {
269
+ target: url,
270
+ method: "GET",
271
+ stage: "acquisition",
272
+ retryable: true,
273
+ });
274
+ }
275
+ const channels = {
276
+ ...cached.channels,
277
+ [HEADER]: {
278
+ ...cached.channels[HEADER],
279
+ content: _a.#refreshAfter304(cachedHeader, responseHeaders, requestHeaders),
280
+ },
281
+ };
282
+ const written = await ctx.entries.write(pathname, {
283
+ channels,
284
+ });
285
+ if (Results.isErrorStatus(written.status))
286
+ return _a.#passthrough(written);
287
+ return { status: 200 };
288
+ }
289
+ if (fetched.mimetype === "text/event-stream"
290
+ && fetched.response?.body !== null
291
+ && fetched.response?.body !== undefined) {
292
+ return this.#openEventStream(address, fetched, ctx);
293
+ }
235
294
  let materialized;
236
295
  try {
237
- materialized = await WebFetcher.materialize(fetched, ctx.projection);
296
+ materialized = await WebFetcher.materialize(fetched, ctx.projection, ctx.signal);
238
297
  }
239
- catch (err) {
298
+ catch (error) {
240
299
  if (ctx.signal?.aborted === true)
241
300
  return _a.#cancelled(url, "GET");
242
- if (err instanceof WebMaterializationError) {
243
- return _a.#materializationFailure(url, "GET", err);
301
+ if (error instanceof WebMaterializationError) {
302
+ return _a.#materializationFailure(url, "GET", error);
244
303
  }
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.",
304
+ throw error;
305
+ }
306
+ if (materialized === null) {
307
+ 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.`, {
308
+ target: url,
309
+ method: "GET",
310
+ mimetype: fetched.mimetype,
311
+ stage: "materialization",
312
+ recovery: "Inspect #header or use a byte-capable client.",
267
313
  retryable: false,
268
314
  });
269
315
  }
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));
316
+ const written = await ctx.entries.write(pathname, {
317
+ channels: WebFetcher.materializedChannels(materialized, { url, method: "GET" }),
318
+ });
319
+ if (Results.isErrorStatus(written.status))
320
+ return _a.#passthrough(written);
321
+ return { status: 200 };
322
+ }
323
+ async #openEventStream(address, fetched, ctx) {
324
+ const response = fetched.response;
325
+ if (response?.body === null || response?.body === undefined) {
326
+ throw new Error("SSE preparation lost its response body.");
275
327
  }
276
- return this.#fetchStream(statement.target, ctx, "GET", undefined);
328
+ const local = new AbortController();
329
+ const handle = {
330
+ cancel: async () => {
331
+ local.abort();
332
+ await response.body?.cancel().catch(() => { });
333
+ },
334
+ };
335
+ const written = await ctx.entries.write(address.pathname, _a.#seedEntry());
336
+ if (Results.isErrorStatus(written.status))
337
+ return _a.#passthrough(written);
338
+ const subscription = await ctx.subscriptions.open(address.pathname, handle);
339
+ if (fetched.header !== undefined) {
340
+ await subscription.notifyChunk(HEADER, fetched.header, "text/plain");
341
+ }
342
+ void _a.#settleEventStream(subscription, response, {
343
+ url: address.url,
344
+ method: "GET",
345
+ signal: local.signal,
346
+ errorDetailLimit: this.#errorDetailLimit,
347
+ }).catch((error) => {
348
+ console.error("HTTP SSE terminal cleanup failed", { url: address.url, error });
349
+ });
350
+ return { shape: "passthrough", status: 102 };
277
351
  }
278
352
  // EDIT -> PUT the body (full-resource replace). `<L>` has no meaning against a
279
353
  // remote resource - reject rather than silently ignore the model's intent.
@@ -301,12 +375,12 @@ class Http {
301
375
  retryable: false,
302
376
  });
303
377
  }
304
- return this.#fetchStream(statement.target, ctx, "PUT", statement.body ?? "");
378
+ return this.#request(statement.target, ctx, "PUT", statement.body ?? "");
305
379
  }
306
380
  async edit(statement, ctx) {
307
381
  return this.editBatch([statement], ctx);
308
382
  }
309
- // KILL -> DELETE the resource. Distinct from SEND[410] (which drops the local
383
+ // KILL -> DELETE the resource. Distinct from SEND signal 410 (which drops the local
310
384
  // cached entry): KILL is an HTTP DELETE request to the remote.
311
385
  async kill(statement, ctx) {
312
386
  if (statement.target === null || statement.target.kind !== "url") {
@@ -316,7 +390,7 @@ class Http {
316
390
  retryable: false,
317
391
  });
318
392
  }
319
- return this.#fetchStream(statement.target, ctx, "DELETE", statement.body ?? undefined);
393
+ return this.#request(statement.target, ctx, "DELETE", statement.body ?? undefined);
320
394
  }
321
395
  // SEND dispatch — status-code-as-verb (SPEC {§op-surface}).
322
396
  // 200 -> request with body (POST), stream response
@@ -335,7 +409,7 @@ class Http {
335
409
  const status = statement.signal;
336
410
  if (status === 200) {
337
411
  const body = statement.body?.raw ?? "";
338
- return this.#fetchStream(statement.target, ctx, "POST", body);
412
+ return this.#request(statement.target, ctx, "POST", body);
339
413
  }
340
414
  if (status === 410) {
341
415
  const address = _a.#address(statement.target);
@@ -345,7 +419,7 @@ class Http {
345
419
  }
346
420
  if (status === 499) {
347
421
  // Cancellation is routed by the engine to the subscription's
348
- // SubscriptionHandle.cancel (registered in #fetchStream). Nothing
422
+ // SubscriptionHandle.cancel (registered by #request). Nothing
349
423
  // for the scheme to do at the op level.
350
424
  return { shape: "passthrough", status: 200 };
351
425
  }
@@ -356,13 +430,14 @@ class Http {
356
430
  retryable: false,
357
431
  });
358
432
  }
359
- // {§http-lifecycle} One direct-operation path owns seeding,
360
- // subscription cancellation, response materialization, and settlement.
361
- async #fetchStream(target, ctx, method, body) {
433
+ // Mutation responses use the same entry/channel and subscription primitives
434
+ // as every live producer; GET acquisition belongs solely to
435
+ // prepareRepresentation.
436
+ async #request(target, ctx, method, body) {
362
437
  const address = _a.#address(target);
363
438
  if (!(address instanceof NetworkAddress))
364
439
  return address;
365
- const url = method === "GET" ? rewriteAcquisitionTarget(address.url) : address.url;
440
+ const { url } = address;
366
441
  const { pathname } = address;
367
442
  const headers = target.headers ?? [];
368
443
  const publishedChannel = target.fragment ?? _a.manifest.defaultChannel;
@@ -375,49 +450,7 @@ class Http {
375
450
  retryable: false,
376
451
  });
377
452
  }
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
- // Local AbortController for force-cancel from outside (SEND[499]).
453
+ // Local AbortController for force-cancel from outside (SEND signal 499).
421
454
  const local = new AbortController();
422
455
  const handle = { cancel: () => local.abort() };
423
456
  // {§http-lifecycle} open() binds an existing entry, so the handler seeds
@@ -426,94 +459,21 @@ class Http {
426
459
  if (Results.isErrorStatus(written.status))
427
460
  return _a.#passthrough(written);
428
461
  // 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 });
462
+ // OR our local teardown. Wire it so either path aborts acquisition.
463
+ const subscription = await ctx.subscriptions.open(pathname, handle);
431
464
  const onAbort = () => local.abort();
432
465
  subscription.addEventListener("abort", onAbort, { once: true });
433
- let detached = false;
434
466
  try {
435
467
  const response = await fetch(url, {
436
468
  method,
437
469
  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
470
  headers: headers.some(([k]) => k.toLowerCase() === "user-agent")
442
- ? [...headers, ...conditional]
443
- : [["User-Agent", BROWSER_UA], ...headers, ...conditional],
471
+ ? headers
472
+ : [["User-Agent", DEFAULT_WEB_UA], ...headers],
444
473
  signal: local.signal,
445
474
  redirect: "follow",
446
475
  });
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
476
  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
477
  // {§http-lifecycle}/{§mimetype-classifier} String channels retain
518
478
  // textual response data. Binary input is transient: an installed
519
479
  // reader may derive Unicode, otherwise the durable body is a typed
@@ -599,8 +559,7 @@ class Http {
599
559
  return result;
600
560
  }
601
561
  finally {
602
- if (!detached)
603
- subscription.removeEventListener("abort", onAbort);
562
+ subscription.removeEventListener("abort", onAbort);
604
563
  }
605
564
  }
606
565
  // {§http-manifest}/{§http-lifecycle} Seed the declared channel shape before
@@ -611,13 +570,18 @@ class Http {
611
570
  mimetype,
612
571
  state: "active",
613
572
  }]));
614
- return { channels, tags: [] };
573
+ return { channels };
615
574
  }
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.
575
+ // {§revalidation} The body and header must have settled successfully. An
576
+ // auxiliary channel may have terminally failed without invalidating body.
619
577
  static #representationComplete(entry) {
620
- return Object.values(entry.channels).every(({ state }) => state === "static" || state === "closed");
578
+ const bodyState = entry.channels[BODY]?.state;
579
+ const headerState = entry.channels[HEADER]?.state;
580
+ const successful = (state) => state === "static" || state === "closed";
581
+ const terminal = (state) => successful(state) || state === "errored";
582
+ return successful(bodyState)
583
+ && successful(headerState)
584
+ && Object.values(entry.channels).every(({ state }) => terminal(state));
621
585
  }
622
586
  // {§revalidation} Direct READ and exact FIND share the complete reusable
623
587
  // representation predicate; freshness only decides how READ uses a match.
@@ -636,13 +600,16 @@ class Http {
636
600
  // {§revalidation} Origin headers come first; authoritative package method
637
601
  // and acquisition metadata are appended last.
638
602
  static async #writeHeader(subscription, method, status, statusText, responseHeaders, requestHeaders) {
603
+ await subscription.notifyChunk(HEADER, _a.#responseHeader(method, status, statusText, responseHeaders, requestHeaders), "text/plain");
604
+ }
605
+ static #responseHeader(method, status, statusText, responseHeaders, requestHeaders) {
639
606
  const lines = [`HTTP ${status} ${statusText}`];
640
607
  for (const [k, v] of responseHeaders)
641
608
  lines.push(`${k}: ${v}`);
642
609
  lines.push(`${REQUEST_METHOD}: ${method}`);
643
610
  lines.push(`${FETCHED_AT}: ${new Date().toISOString()}`);
644
611
  lines.push(cacheVariantEvidence(classifyCacheVariant(requestHeaders, responseHeaders)));
645
- await subscription.notifyChunk(HEADER, lines.join("\n"), "text/plain");
612
+ return lines.join("\n");
646
613
  }
647
614
  static async #writeProjectionIdentity(subscription, identity) {
648
615
  await subscription.notifyChunk(HEADER, `\n${WebFetcher.projectionEvidence(identity)}`, "text/plain");
@@ -652,6 +619,11 @@ class Http {
652
619
  static async #settleEventStream(subscription, response, options) {
653
620
  try {
654
621
  const events = await _a.#streamEvents(subscription, response);
622
+ if (options.signal.aborted) {
623
+ const result = _a.#cancelled(options.url, options.method);
624
+ await subscription.close(result, result.problem?.detail);
625
+ return;
626
+ }
655
627
  await subscription.close({ status: 200 }, `SSE stream; ${events} events`);
656
628
  }
657
629
  catch (error) {
@@ -873,6 +845,9 @@ class Http {
873
845
  static #projectionIdentity(priorHeader) {
874
846
  return _a.#packageHeaderValue(priorHeader, PROJECTION_ID_HEADER);
875
847
  }
848
+ static #materializerIdentity(priorHeader) {
849
+ return _a.#packageHeaderValue(priorHeader, MATERIALIZER_ID_HEADER);
850
+ }
876
851
  static #cacheVariant(priorHeader) {
877
852
  const value = _a.#packageHeaderValue(priorHeader, CACHE_VARIANT_HEADER);
878
853
  return value === "default" || value === "bypass" ? value : undefined;
@@ -881,10 +856,22 @@ class Http {
881
856
  return responseMimetype(lastHeaderValue(header, "content-type") ?? null);
882
857
  }
883
858
  static async #projectionCurrent(header, projection) {
859
+ const sourceMimetype = _a.#sourceMimetype(header);
860
+ const isHtml = MimetypeClassifier.isHtml(sourceMimetype);
861
+ const materializerIdentity = _a.#materializerIdentity(header);
862
+ if (materializerIdentity !== undefined) {
863
+ if (!WebFetcher.materializerCurrent(materializerIdentity))
864
+ return false;
865
+ if (materializerIdentity.startsWith("tavily-extract:")
866
+ || materializerIdentity === "origin-markdown:v1")
867
+ return true;
868
+ }
869
+ if (isHtml && materializerIdentity === undefined)
870
+ return false;
884
871
  const storedIdentity = _a.#projectionIdentity(header);
885
872
  if (storedIdentity === undefined)
886
- return true;
887
- return await projection.identity(_a.#sourceMimetype(header)) === storedIdentity;
873
+ return !isHtml;
874
+ return await projection.identity(sourceMimetype) === storedIdentity;
888
875
  }
889
876
  // RFC 9111 §3.2 permits a processed cache to retain the metadata that its
890
877
  // stored body depends upon. Other 304 fields update by name; package-owned
@@ -899,7 +886,15 @@ class Http {
899
886
  ...retained.map(({ name, value }) => [name, value]),
900
887
  ...updates,
901
888
  ];
902
- const projectionIdentity = _a.#projectionIdentity(header);
889
+ const packageEvidence = [
890
+ MATERIALIZER_ID_HEADER,
891
+ PROJECTION_ID_HEADER,
892
+ TAVILY_REQUEST_ID_HEADER,
893
+ TAVILY_CREDITS_HEADER,
894
+ ].flatMap((field) => {
895
+ const value = _a.#packageHeaderValue(header, field);
896
+ return value === undefined ? [] : [`${field}: ${value}`];
897
+ });
903
898
  return [
904
899
  lines[0] ?? "HTTP 200 OK",
905
900
  ...retained.map(({ line }) => line),
@@ -907,9 +902,7 @@ class Http {
907
902
  `${REQUEST_METHOD}: GET`,
908
903
  `${FETCHED_AT}: ${new Date().toISOString()}`,
909
904
  cacheVariantEvidence(classifyCacheVariant(requestHeaders, merged)),
910
- ...(projectionIdentity === undefined
911
- ? []
912
- : [WebFetcher.projectionEvidence(projectionIdentity)]),
905
+ ...packageEvidence,
913
906
  ].join("\n");
914
907
  }
915
908
  static #revalidationCorresponds(priorHeader, responseHeaders) {
@@ -979,20 +972,10 @@ class Http {
979
972
  });
980
973
  }
981
974
  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.`, {
975
+ return _a.#bad(500, "http", "projection-failed", `HTTP ${method} ${url} acquired content, but its readable projection failed.`, {
986
976
  target: url,
987
977
  method,
988
978
  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
979
  stage: "projection",
997
980
  retryable: false,
998
981
  });