@plurnk/plurnk-schemes-http 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.defaults CHANGED
@@ -14,10 +14,6 @@
14
14
  PLURNK_SCHEMES_HTTP_FETCH_TIMEOUT=30000
15
15
 
16
16
  # ── Defaults (floor-set; override to change) ────────────────────────────────
17
- # Tavily Extract extraction depth ("basic" or "advanced").
18
- PLURNK_SCHEMES_HTTP_TAVILY_DEPTH=basic
19
- # Tavily Extract request timeout in ms.
20
- PLURNK_SCHEMES_HTTP_TAVILY_TIMEOUT_MS=30000
21
17
  # Per-URL freshness TTL in ms: a re-read of a URL fetched within this window
22
18
  # serves the stored copy with ZERO round-trips (no conditional GET). This makes
23
19
  # a just-materialized web entry immediately readable like any other entry.
@@ -36,6 +32,9 @@ PLURNK_SCHEMES_HTTP_SSE_MAX_BUFFER_CHARS=1048576
36
32
  PLURNK_SCHEMES_HTTP_ERROR_DETAIL_LIMIT=512
37
33
 
38
34
  # ── Optional modes (absence IS the mode — never floor-set) ──────────────────
39
- # Optional Tavily API key for HTML-to-Markdown extraction. When omitted/unset,
40
- # the installed HTML mimetype projection is used as the local floor.
41
- # TAVILY_API_KEY=
35
+ # The documented third-party materializer showcase: install the
36
+ # @plurnk/plurnk-schemes-http-tavily plugin, then select it and provide its
37
+ # key. Absence (unset) of either keeps the installed HTML projection as the
38
+ # local floor; the plugin documents its own depth/timeout knobs.
39
+ # PLURNK_SCHEMES_HTTP_MATERIALIZER=tavily-extract
40
+ # TAVILY_API_KEY=...
package/README.md CHANGED
@@ -45,9 +45,10 @@ projection ceiling returns `413`.
45
45
  - WebFetcher checks automatic byte targets and redirects; direct HTTP and
46
46
  WebSocket retain their explicit-target authority.
47
47
  - Generic GETs negotiate origin Markdown first. When the origin returns HTML,
48
- configured Tavily produces `body`; otherwise the installed HTML reader is the
49
- local route. Recoverable Tavily failures use that same reader as a `203`
50
- recovery floor. Hard provider failures do not silently change producers.
48
+ a selected materializer plugin ({§http-materializer-plugins}) produces
49
+ `body`; otherwise the installed HTML reader is the local route. Recoverable
50
+ materializer failures use that same reader as a `203` recovery floor. Hard
51
+ provider failures do not silently change producers.
51
52
  - `body`, `header`, and `html` settle independently. The selected channel
52
53
  determines operation success, so raw server source and evidence remain usable
53
54
  when body production fails, and a provider body can survive origin transport
@@ -79,11 +80,11 @@ plurnk start
79
80
  ```
80
81
 
81
82
  The shipped `.env.defaults` is the canonical operator configuration registry.
82
- To enable Tavily Extract for generic public HTML materialization, set
83
- `TAVILY_API_KEY` in `.env`. Depth and client timeout are controlled by
84
- `PLURNK_SCHEMES_HTTP_TAVILY_DEPTH` and
85
- `PLURNK_SCHEMES_HTTP_TAVILY_TIMEOUT_MS`. Without Tavily,
86
- `@plurnk/plurnk-mimetypes-text-html` supplies the local body.
83
+ To select a page-materializer plugin for generic public HTML materialization,
84
+ install it and set `PLURNK_SCHEMES_HTTP_MATERIALIZER=<id>` in `.env` (the
85
+ `@plurnk/plurnk-schemes-http-tavily` showcase plugin supplies `tavily-extract`).
86
+ Without a selection, `@plurnk/plurnk-mimetypes-text-html` supplies the local
87
+ body.
87
88
 
88
89
  ## Verify
89
90
 
package/SPEC.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # plurnk-schemes-http — Specification
2
2
 
3
3
  This package owns the `http(s)://` request/response scheme, the `ws(s)://`
4
- full-duplex scheme, plus automatic entry-acquisition, Tavily Extract, and local
5
- HTML projection foundations. Both handlers implement the DB-free `SchemeCtx` author contract.
4
+ full-duplex scheme, plus automatic entry-acquisition, pluggable page
5
+ materializers, and the local HTML projection foundation. Both handlers implement
6
+ the DB-free `SchemeCtx` author contract.
6
7
 
7
8
  ## §http-manifest §1 HTTP manifest
8
9
 
@@ -66,13 +67,13 @@ flowchart TD
66
67
  origin -->|text/markdown| markdown["Use origin Markdown as body<br/>and request an HTML variant"]
67
68
  origin -->|server HTML| source["Retain exact server HTML"]
68
69
  origin -->|transport unavailable<br/>after public admission| noSource["No HTML source"]
69
- source --> eligible{"Generic public request<br/>with Tavily configured?"}
70
+ source --> eligible{"Generic public request<br/>with a materializer selected?"}
70
71
  noSource --> eligible
71
72
  eligible -->|no| local["Installed HTML projection"]
72
- eligible -->|yes| tavily["Tavily Extract Markdown"]
73
- tavily -->|success| providerBody["Tavily body"]
74
- tavily -->|recoverable failure<br/>and source exists| recovery["Local projection; body 203"]
75
- tavily -->|hard failure or<br/>no recovery source| bodyError["Body errored"]
73
+ eligible -->|yes| mat["Materializer Markdown"]
74
+ mat -->|success| providerBody["Materializer body"]
75
+ mat -->|recoverable failure<br/>and source exists| recovery["Local projection; body 203"]
76
+ mat -->|hard failure or<br/>no recovery source| bodyError["Body errored"]
76
77
  local -->|present, including empty| localBody["Local body"]
77
78
  local -->|absent or no source| bodyError
78
79
  markdown --> settle["Write complete canonical channels<br/>and durable producer results"]
@@ -93,7 +94,7 @@ but cold and warm representations pass through the same core projection.
93
94
  | Response | `body` | Auxiliary materialization | Completion |
94
95
  | --------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------- |
95
96
  | Negotiated origin Markdown | Exact origin Markdown | Independently acquired server source in `html` when found | Static representation, then core projection |
96
- | HTML-page production | Tavily Markdown, local Markdown, or independent error | Exact server source in `html` when origin supplied it | Static representation, then core projection |
97
+ | HTML-page production | Materializer Markdown, local Markdown, or independent error | Exact server source in `html` when origin supplied it | Static representation, then core projection |
97
98
  | `text/event-stream` | One `data` value plus newline per `text/plain` chunk | Initial response in `header` | `102`; origin close settles subscription |
98
99
  | No response body | Present empty text | Response and package metadata in `header` | Static representation; body READ is `204` |
99
100
  | Origin HTTP `4xx`/`5xx` | Preserve available origin or independently produced text | Exact response evidence; origin-backed channels errored | Selected channel's durable outcome |
@@ -138,52 +139,51 @@ mutation responses retain incremental streaming.
138
139
  | Configured binary with reader | Apply the bounded byte projection | Derived Unicode plus source type and identity |
139
140
  | Configured binary without reader | Cancel without retaining bytes | `null`; the consumer applies its absence policy |
140
141
  | Binary input exceeds the common bound | Cancel and preserve the typed cause | `WebMaterializationError` caused by `ProjectionInputLimitError` |
141
- | Negotiated origin `text/markdown` | Accept it without Tavily | Exact Markdown in `body`; independently requested source in `html` |
142
- | Eligible HTML with Tavily configured | Use Tavily as the body producer | Tavily Markdown in `body`; origin server source in `html` |
143
- | Ineligible HTML or Tavily not configured | Use the installed HTML projection | Local Markdown in `body`; origin server source in `html` |
144
- | Recoverable Tavily failure with source HTML | Use the installed projection as recovery | Local Markdown in `body` with terminal `203`; source in `html` |
145
- | Hard Tavily failure | Preserve evidence; do not run local recovery | `body` errored; independently successful `html`/`header` survive |
146
- | Tavily success after origin transport loss | Preserve provider body and origin failure | `body` static; `html` errored |
142
+ | Negotiated origin `text/markdown` | Accept it without the materializer | Exact Markdown in `body`; independently requested source in `html` |
143
+ | Eligible HTML with a materializer selected | Use the materializer as the body producer | Materializer Markdown in `body`; origin server source in `html` |
144
+ | Ineligible HTML or no materializer selected | Use the installed HTML projection | Local Markdown in `body`; origin server source in `html` |
145
+ | Recoverable materializer failure with HTML | Use the installed projection as recovery | Local Markdown in `body` with terminal `203`; source in `html` |
146
+ | Hard materializer failure | Preserve evidence; do not run local recovery | `body` errored; independently successful `html`/`header` survive |
147
+ | Materializer success after origin loss | Preserve provider body and origin failure | `body` static; `html` errored |
147
148
  | Projection implementation throws | Stop and preserve the cause | `WebMaterializationError` with stage `projection` |
148
149
 
149
150
  A projection object is present even when its content is `""`; only `null`
150
151
  denotes absence. A materialization exception retains its original `cause`; it
151
152
  never enters the absence channel.
152
153
 
153
- #### §tavily-extract Tavily Extract boundary
154
-
155
- Tavily is eligible only for a credential-free generic request whose target has
156
- been admitted as public. Authored request metadata—including an authored
157
- `Accept` field—makes the request ineligible. The package-generated Markdown
158
- negotiation, browser-compatible `User-Agent`, and conditional cache fields are
159
- transport mechanics, not authored metadata. No target headers or origin
160
- credentials cross the provider boundary.
154
+ #### §http-materializer-plugins Materializer plugins
155
+
156
+ `PLURNK_SCHEMES_HTTP_MATERIALIZER` selects one discovered `http-materializer`
157
+ plugin by id; unset means the installed HTML projection is the only body
158
+ producer. A materializer package declares
159
+ `plurnk: { kind: "http-materializer", materializers: [{ id, module }] }` and
160
+ exports one `HttpMaterializer` per entry under the executor family's
161
+ discovery, trust, and one-flat-id-namespace rules ({§plugin-discovery}). The
162
+ selected materializer is consulted only for a credential-free generic request
163
+ whose target has been admitted as public. Authored request metadata—including
164
+ an authored `Accept` field—makes the request ineligible. The package-generated
165
+ Markdown negotiation, browser-compatible `User-Agent`, and conditional cache
166
+ fields are transport mechanics, not authored metadata. No target headers or
167
+ origin credentials cross the materializer boundary.
161
168
 
162
169
  When no `Accept` is authored, origin acquisition offers
163
170
  `text/markdown, text/html;q=0.9, */*;q=0.1`. An origin `text/markdown`
164
- representation wins and Tavily is skipped; a second origin request with
165
- `Accept: text/html` may populate `html`. An authored `Accept` value is sent
166
- unchanged and makes any returned HTML use the local projection route.
167
-
168
- One provider request is `POST https://api.tavily.com/extract` with bearer
169
- authentication and exactly one URL, configured `basic` or `advanced` depth,
170
- `format: "markdown"`, and `include_usage: true`. A package-owned abort timeout
171
- bounds the call. Success requires Markdown plus `request_id` and
172
- `usage.credits`; those facts remain durable in `header`.
173
-
174
- | Provider outcome | Classification | Body behavior when server HTML exists |
175
- | ---------------------------------------- | -------------- | ------------------------------------------------------ |
176
- | Success with required evidence | Success | Use Tavily Markdown |
177
- | Caller cancellation | Cancelled | Return exact `499`; no recovery |
178
- | Client timeout or transport failure | Recoverable | Local projection, terminal `203` |
179
- | `429`, `5xx`, or `failed_results` | Recoverable | Local projection, terminal `203` |
180
- | `401` or `403` | Hard | `tavily-authentication-failed`; no local projection |
181
- | Other `4xx` | Hard | `tavily-provider-rejected`; no local projection |
182
- | Malformed success or missing evidence | Hard | `tavily-invalid-response`; no local projection |
183
-
184
- Without server HTML, no local recovery input exists. A Tavily success can still
185
- close `body`, but every Tavily failure remains the body failure and `html`
186
- closes errored.
171
+ representation wins and the materializer is skipped; a second origin request
172
+ with `Accept: text/html` may populate `html`. An authored `Accept` value is
173
+ sent unchanged and makes any returned HTML use the local projection route.
174
+
175
+ The materializer's `eligible(url)` returns its identity string when it will
176
+ serve the URL (its credentials and supported targets are its own); `extract`
177
+ returns `success` (its sanitized Markdown body plus its own evidence headers),
178
+ `recoverable` (local projection fallback with the materializer's evidence and
179
+ terminal `203`), or `hard` (an exact materializer-owned Problem; no local
180
+ recovery). A materializer `extract` throw is the materialization failure, never
181
+ a silent fallback. A selected id with no installed provider fails hard rather
182
+ than degrading silently.
183
+
184
+ Without server HTML, no local recovery input exists. A materializer success can
185
+ still close `body`, but every materializer failure remains the body failure and
186
+ `html` closes errored.
187
187
 
188
188
  #### §http-channel-outcomes Channel outcomes
189
189
 
@@ -201,9 +201,19 @@ representation. A missing source variant is an explicit empty `errored`
201
201
  channel, never an absent fact that later cache use can reinterpret as successful
202
202
  empty content. A direct non-success HTTP response likewise remains available as
203
203
  evidence, while each origin-backed channel carries an exact
204
- `http-response-status` producer Problem. A Tavily-produced body and the
204
+ `http-response-status` producer Problem. A materializer-produced body and the
205
205
  acquisition `header` remain independent of an unavailable origin source.
206
206
 
207
+ §http-llms-txt **Origin llms.txt companions.** After a successful generic GET
208
+ materialization, the scheme opportunistically acquires `<origin>/llms.txt`
209
+ once per origin per TTL window and materializes it as its own origin entry
210
+ (`https://host/llms.txt`), surfaceable by FIND. A missing companion, any
211
+ non-2xx, binary content, or a transport failure is quiet: the companion is
212
+ never fabricated, the failed probe is remembered (not retried per READ), and
213
+ the piggyback never fails the READ that carried it. The companion itself
214
+ never recurses. This is lazy by construction — one extra request only when
215
+ an origin is first being read.
216
+
207
217
  ## §http-status §4 HTTP status mapping
208
218
 
209
219
  | Outcome | Operation status |
@@ -214,7 +224,7 @@ acquisition `header` remain independent of an unavailable origin source.
214
224
  | Selected origin-backed channel received HTTP `4xx`/`5xx` | Exact durable `http-response-status` Problem |
215
225
  | Selected HTML-page channel fails | That channel's exact durable producer Problem |
216
226
  | Local HTML projection is absent | `422` (`no-readable-projection`) |
217
- | Recoverable Tavily failure uses local projection | Readable body with durable status `203` |
227
+ | Recoverable materializer failure uses local projection | Readable body with durable status `203` |
218
228
  | Finite textual or projected-binary response | Universal READ result |
219
229
  | Finite empty text response | `204` |
220
230
  | Finite binary response has no readable projection | `415` (`binary-response-unsupported`) |
@@ -235,8 +245,8 @@ acquisition `header` remain independent of an unavailable origin source.
235
245
  An HTTP error status is still a successfully acquired direct response: its
236
246
  content and response evidence are preserved, and origin-backed channels carry
237
247
  that exact status as durable producer evidence. HTML still enters page
238
- production, so a successful Tavily `body` can remain readable while the origin
239
- `html` records the HTTP error. Automatic WebFetcher acquisition instead treats
248
+ production, so a successful materializer `body` can remain readable while the
249
+ origin `html` records the HTTP error. Automatic WebFetcher acquisition instead treats
240
250
  a non-2xx response as unavailable. Handler failures use RFC 9457 Problem Details.
241
251
  Caught direct-acquisition diagnostics are bounded by
242
252
  `PLURNK_SCHEMES_HTTP_ERROR_DETAIL_LIMIT` in model-facing detail while complete
@@ -260,7 +270,7 @@ handler does not sniff or guess unknown bytes.
260
270
  | ----------------- | --------------------------------------------------------------------------------------------------------- |
261
271
  | Platform | Node ≥26 native fetch, streams, abort signals, decoding, DNS, and `WebSocket` |
262
272
  | SSE | `eventsource-parser` for bounded WHATWG event-stream framing |
263
- | Tavily Extract | Optional Tavily Extract API primitive when `TAVILY_API_KEY` is present |
273
+ | Materializer plugins | Discovered `http-materializer` packages selected by `PLURNK_SCHEMES_HTTP_MATERIALIZER` {§http-materializer-plugins} |
264
274
 
265
275
  ### §http-config Operator configuration
266
276
 
@@ -268,9 +278,7 @@ handler does not sniff or guess unknown bytes.
268
278
  | --------------------- | -------------------------------------------------------------------------------------------------------- |
269
279
  | Canonical registry | Shipped `.env.defaults`; the daemon assembles it as a set-if-unset floor {§operator-config-env-defaults} |
270
280
  | Required values | Missing or invalid required values fail at readiness/owning read; code carries no hidden fallback |
271
- | Tavily API Key | Optional `# TAVILY_API_KEY=`; enables Tavily Extract for eligible public HTML |
272
- | Tavily Extract Depth | `PLURNK_SCHEMES_HTTP_TAVILY_DEPTH=basic`; only `basic` or `advanced` |
273
- | Tavily client timeout | Positive `PLURNK_SCHEMES_HTTP_TAVILY_TIMEOUT_MS=30000` |
281
+ | Materializer selection | `PLURNK_SCHEMES_HTTP_MATERIALIZER=` names a discovered materializer id; unset = local projection only |
274
282
 
275
283
  ### §automatic-fetch-check Automatic acquisition URL check
276
284
 
@@ -279,7 +287,7 @@ acquisition, it accepts credential-free HTTP(S) targets only when every
279
287
  resolved address is ordinary globally reachable unicast, and repeats the check
280
288
  before every manually followed redirect. A refused or unresolvable target is
281
289
  the ordinary unavailable `null` result. A transport failure after public
282
- admission may still enter Tavily page production.
290
+ admission may still enter materializer page production.
283
291
 
284
292
  Direct HTTP operations and WebSocket connections do not use this check. Loopback,
285
293
  private, and link-local destinations are therefore valid explicit targets.
@@ -296,12 +304,12 @@ check therefore makes no DNS-rebinding or total-egress claim.
296
304
 
297
305
  | Concern | Contract |
298
306
  | -------------- | ---------------------------------------------------------------------------------------------------------- |
299
- | Direct gate | Explicit targets use native origin transport; only a generic public request grants Tavily authority |
307
+ | Direct gate | Explicit targets use native origin transport; only a generic public request grants materializer authority |
300
308
  | Automatic gate | Target and redirects require public admission; accepted generic HTML follows the same page producer |
301
- | Body owner | Origin Markdown wins; otherwise configured eligible Tavily is structural, with one local projection floor |
309
+ | Body owner | Origin Markdown wins; otherwise the configured eligible materializer is structural, with one local projection floor |
302
310
  | Source owner | `html` is exact server-source HTML; it is never provider-generated or DOM-generated |
303
311
  | Projection | A present projection, including `""`, becomes `body`; `null` alone means absence |
304
- | Cancellation | One caller signal spans origin, auxiliary origin, projection, and Tavily work |
312
+ | Cancellation | One caller signal spans origin, auxiliary origin, projection, and materializer work |
305
313
 
306
314
  ### §host-rewrite Acquisition target rewrite
307
315
 
@@ -317,7 +325,7 @@ are checked under {§automatic-fetch-check} only when `WebFetcher` acquires them
317
325
  Acquired responses append package-owned `x-plurnk-request-method`,
318
326
  `x-plurnk-fetched-at`, and `x-plurnk-cache-variant` fields after origin headers.
319
327
  Page bodies append `x-plurnk-materializer-id`; local projections append
320
- `x-plurnk-projection-id`; Tavily appends status, route, request, usage, timing,
328
+ `x-plurnk-projection-id`; The materializer appends its own evidence headers;
321
329
  and bounded failure evidence. Only package metadata after the acquisition stamp
322
330
  is authoritative.
323
331
  Plurnk stores one representation per canonical URL rather than a variant set:
@@ -388,16 +396,16 @@ different source representation.
388
396
 
389
397
  A stored derived representation requires the current projection identity. A
390
398
  stored page body also requires its current route: negotiated origin Markdown,
391
- local projection while Tavily is unconfigured, metadata-ineligible local
392
- projection, configured Tavily depth/version, or the corresponding recoverable
393
- local-fallback route. Enabling Tavily or changing depth invalidates the affected
394
- route.
399
+ local projection while no materializer is selected, metadata-ineligible local
400
+ projection, the configured materializer id, or the corresponding recoverable
401
+ local-fallback route. Selecting or changing the materializer invalidates the
402
+ affected route.
395
403
 
396
404
  Once a page representation leaves its fresh window, Plurnk performs complete
397
405
  reacquisition without old origin validators. Origin `304` can certify origin
398
- bytes, but it cannot certify a composite that may also contain a fresh Tavily
399
- extraction, a newly negotiated Markdown representation, or an independently
400
- acquired HTML variant. Projection or materializer mismatch likewise invalidates
406
+ bytes, but it cannot certify a composite that may also contain a fresh
407
+ materializer extraction, a newly negotiated Markdown representation, or an
408
+ independently acquired HTML variant. Projection or materializer mismatch likewise invalidates
401
409
  body, TTL, and validators together.
402
410
 
403
411
  POST, PUT, and DELETE responses retain their method marker but cannot satisfy a
@@ -435,7 +443,8 @@ operation is cancelled.
435
443
  Top-level `null` is an automatic-acquisition liveness value rather than a thrown
436
444
  failure. Caller cancellation is not liveness: a pre-aborted caller fails before
437
445
  acquisition and a caller abort wins with its exact reason. Package-owned origin
438
- and Tavily timeouts are ordinary classified acquisition outcomes. WebFetcher
446
+ timeouts are ordinary classified acquisition outcomes; materializer timeouts
447
+ are the materializer's own classified outcomes. WebFetcher
439
448
  owns no entry identity, registry selection, selected-channel policy, or query
440
449
  policy; its consumers supply those boundaries and the projection capability.
441
450
 
@@ -1 +1 @@
1
- {"version":3,"file":"Http.d.ts","sourceRoot":"","sources":["../src/Http.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACR,SAAS,EAGT,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,gCAAgC,EAChC,+BAA+B,EAC/B,aAAa,EACb,qBAAqB,EACrB,aAAa,EAOhB,MAAM,wBAAwB,CAAC;AAuMhC,MAAM,CAAC,OAAO,OAAO,IAAK,YAAW,aAAa;;IAC9C,MAAM,CAAC,QAAQ,EAAE,cAAc,CA2B7B;IAIF,cAGC;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAE3B;IAKK,qBAAqB,CACvB,OAAO,EAAE,gCAAgC,EACzC,GAAG,EAAE,SAAS,GACf,OAAO,CAAC,+BAA+B,CAAC,CAS1C;IAkNK,SAAS,CAAC,UAAU,EAAE,SAAS,qBAAqB,EAAE,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAqCxG;IAEK,IAAI,CAAC,SAAS,EAAE,qBAAqB,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAEvF;IAIK,IAAI,CAAC,SAAS,EAAE,aAAa,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAS/E;IAQK,IAAI,CAAC,SAAS,EAAE,aAAa,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAoC/E;CAypBJ"}
1
+ {"version":3,"file":"Http.d.ts","sourceRoot":"","sources":["../src/Http.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACR,SAAS,EAGT,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,gCAAgC,EAChC,+BAA+B,EAC/B,aAAa,EACb,qBAAqB,EACrB,aAAa,EAOhB,MAAM,wBAAwB,CAAC;AAuMhC,MAAM,CAAC,OAAO,OAAO,IAAK,YAAW,aAAa;;IAC9C,MAAM,CAAC,QAAQ,EAAE,cAAc,CA2B7B;IAQF,cAGC;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAE3B;IAKK,qBAAqB,CACvB,OAAO,EAAE,gCAAgC,EACzC,GAAG,EAAE,SAAS,GACf,OAAO,CAAC,+BAA+B,CAAC,CAS1C;IAsRK,SAAS,CAAC,UAAU,EAAE,SAAS,qBAAqB,EAAE,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAqCxG;IAEK,IAAI,CAAC,SAAS,EAAE,qBAAqB,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAEvF;IAIK,IAAI,CAAC,SAAS,EAAE,aAAa,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAS/E;IAQK,IAAI,CAAC,SAAS,EAAE,aAAa,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAoC/E;CA8qBJ"}
package/dist/Http.js CHANGED
@@ -6,7 +6,7 @@ import { createParser } from "eventsource-parser";
6
6
  import { MimetypeClassifier, NetworkAddress, ProjectionInputLimitError, Results, } from "@plurnk/plurnk-schemes";
7
7
  import { readFile } from "node:fs/promises";
8
8
  import ErrorDetail from "./ErrorDetail.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";
9
+ import WebFetcher, { DEFAULT_WEB_UA, CACHE_VARIANT_HEADER, MATERIALIZER_ID_HEADER, PROJECTION_ID_HEADER, cacheVariantEvidence, classifyCacheVariant, WebMaterializationError, } from "./WebFetcher.js";
10
10
  import { responseMimetype } from "./ContentType.js";
11
11
  import { requireNonNegativeIntegerEnv as requireNumEnv } from "./Config.js";
12
12
  // The channel the response body streams into, and the header metadata channel.
@@ -135,6 +135,7 @@ const httpDate = (value, now = Date.now()) => {
135
135
  // The package-shipped model teaching is loaded verbatim and fails hard if absent.
136
136
  // The relative path is identical from src/ during development and dist/ after build.
137
137
  const documentation = await readFile(new URL("../docs/http.md", import.meta.url), "utf-8");
138
+ const LLMS_TEXT_ATTEMPT_TTL_MS = 3_600_000;
138
139
  class Http {
139
140
  static manifest = {
140
141
  name: "http",
@@ -166,6 +167,10 @@ class Http {
166
167
  };
167
168
  #errorDetailLimit;
168
169
  #webFetcher;
170
+ // {§http-llms-txt} — one opportunistic origin-companion attempt per TTL
171
+ // window; success and failure share the timer so a 404 does not re-probe
172
+ // on every READ.
173
+ #llmsTextAttempts = new Map();
169
174
  constructor() {
170
175
  this.#errorDetailLimit = ErrorDetail.configuredLimit();
171
176
  this.#webFetcher = new WebFetcher();
@@ -264,7 +269,49 @@ class Http {
264
269
  });
265
270
  }
266
271
  const responseHeaders = fetched.responseHeaders ?? [];
267
- if (!_a.#revalidationCorresponds(cachedHeader, new Headers(responseHeaders.map(([name, value]) => [name, value])))) {
272
+ if (_a.#revalidationCorresponds(cachedHeader, new Headers(responseHeaders.map(([name, value]) => [name, value])))) {
273
+ const channels = {
274
+ ...cached.channels,
275
+ [HEADER]: {
276
+ ...cached.channels[HEADER],
277
+ content: _a.#refreshAfter304(cachedHeader, responseHeaders, requestHeaders),
278
+ },
279
+ };
280
+ const written = await ctx.entries.write(pathname, {
281
+ channels,
282
+ });
283
+ if (Results.isErrorStatus(written.status))
284
+ return _a.#passthrough(written);
285
+ return { status: 200 };
286
+ }
287
+ // {§revalidation} — a genuinely mismatched 304 (different opaque
288
+ // tags): the conditional is the problem, so fall back to one
289
+ // unconditional GET and acquire normally instead of surfacing
290
+ // an unrecoverable 502. A second 304 has no way out and is the
291
+ // honest failure.
292
+ try {
293
+ fetched = await this.#webFetcher.fetch(url, {
294
+ signal: ctx.signal,
295
+ headers: requestHeaders,
296
+ guarded: false,
297
+ acceptHttpErrors: true,
298
+ preserveUnavailable: true,
299
+ });
300
+ }
301
+ catch (error) {
302
+ if (ctx.signal?.aborted === true && error === ctx.signal.reason) {
303
+ return _a.#cancelled(url, "GET");
304
+ }
305
+ throw error;
306
+ }
307
+ if (fetched === null) {
308
+ return _a.#bad(404, "http", "not-materialized", `The URL ${url} could not be materialized.`, {
309
+ target: url,
310
+ stage: "acquisition",
311
+ retryable: true,
312
+ });
313
+ }
314
+ if (fetched.status === 304) {
268
315
  return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 without identifying the stored representation nominated for revalidation.`, {
269
316
  target: url,
270
317
  method: "GET",
@@ -272,19 +319,7 @@ class Http {
272
319
  retryable: true,
273
320
  });
274
321
  }
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 };
322
+ // Non-304: fall through to ordinary acquisition below.
288
323
  }
289
324
  if (fetched.mimetype === "text/event-stream"
290
325
  && fetched.response?.body !== null
@@ -318,8 +353,46 @@ class Http {
318
353
  });
319
354
  if (Results.isErrorStatus(written.status))
320
355
  return _a.#passthrough(written);
356
+ await this.#piggybackLlmsText(address, ctx);
321
357
  return { status: 200 };
322
358
  }
359
+ // {§http-llms-txt} — after a successful generic GET materialization,
360
+ // acquire <origin>/llms.txt once per TTL window and materialize it as its
361
+ // own https entry. Any failure is quiet: the companion never fails the
362
+ // READ that piggybacked it, and the companion itself never recurses.
363
+ async #piggybackLlmsText(address, ctx) {
364
+ const url = new URL(address.url);
365
+ if (url.pathname === "/llms.txt")
366
+ return;
367
+ const origin = url.origin;
368
+ const last = this.#llmsTextAttempts.get(origin);
369
+ if (last !== undefined && Date.now() - last < LLMS_TEXT_ATTEMPT_TTL_MS)
370
+ return;
371
+ this.#llmsTextAttempts.set(origin, Date.now());
372
+ const llmsUrl = `${origin}/llms.txt`;
373
+ try {
374
+ const fetched = await this.#webFetcher.fetch(llmsUrl, {
375
+ signal: ctx.signal,
376
+ guarded: false,
377
+ acceptHttpErrors: true,
378
+ preserveUnavailable: true,
379
+ });
380
+ if (fetched === null)
381
+ return;
382
+ // A missing companion (404) or any non-2xx is quiet — no entry.
383
+ if ((fetched.status ?? 200) >= 400)
384
+ return;
385
+ const materialized = await WebFetcher.materialize(fetched, ctx.projection, ctx.signal);
386
+ if (materialized === null)
387
+ return;
388
+ await ctx.entries.write(`/${url.host}/llms.txt`, {
389
+ channels: WebFetcher.materializedChannels(materialized, { url: llmsUrl, method: "GET" }),
390
+ });
391
+ }
392
+ catch {
393
+ // Quiet by contract: 404, guard rejection, network error, binary.
394
+ }
395
+ }
323
396
  async #openEventStream(address, fetched, ctx) {
324
397
  const response = fetched.response;
325
398
  if (response?.body === null || response?.body === undefined) {
@@ -860,11 +933,9 @@ class Http {
860
933
  const isHtml = MimetypeClassifier.isHtml(sourceMimetype);
861
934
  const materializerIdentity = _a.#materializerIdentity(header);
862
935
  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;
936
+ // {§http-materializer-plugins} — a stored body produced by the current
937
+ // configured materializer (or the built-in producers) is current.
938
+ return WebFetcher.materializerCurrent(materializerIdentity);
868
939
  }
869
940
  if (isHtml && materializerIdentity === undefined)
870
941
  return false;
@@ -886,15 +957,34 @@ class Http {
886
957
  ...retained.map(({ name, value }) => [name, value]),
887
958
  ...updates,
888
959
  ];
889
- const packageEvidence = [
890
- MATERIALIZER_ID_HEADER,
891
- PROJECTION_ID_HEADER,
892
- TAVILY_REQUEST_ID_HEADER,
893
- TAVILY_CREDITS_HEADER,
894
- ].flatMap((field) => {
960
+ // Materializer/provider evidence headers are plugin-owned (unknown names);
961
+ // a 304 does not re-materialize the stored body, so its evidence is
962
+ // retained verbatim beside the rebuilt framework fields. Framework-owned
963
+ // x-plurnk-* fields (cache-variant, materializer/projection ids, method,
964
+ // fetched-at stamp) are rebuilt or omitted, never duplicated stale.
965
+ const rebuilt = [MATERIALIZER_ID_HEADER, PROJECTION_ID_HEADER].flatMap((field) => {
895
966
  const value = _a.#packageHeaderValue(header, field);
896
967
  return value === undefined ? [] : [`${field}: ${value}`];
897
968
  });
969
+ const frameworkOwned = new Set([
970
+ CACHE_VARIANT_HEADER.toLowerCase(),
971
+ MATERIALIZER_ID_HEADER.toLowerCase(),
972
+ PROJECTION_ID_HEADER.toLowerCase(),
973
+ REQUEST_METHOD.toLowerCase(),
974
+ FETCHED_AT.toLowerCase(),
975
+ ]);
976
+ const packageEvidence = [
977
+ ...rebuilt,
978
+ ...lines.slice(1).filter((line) => {
979
+ const colon = line.indexOf(":");
980
+ if (colon <= 0)
981
+ return false;
982
+ const name = line.slice(0, colon).toLowerCase();
983
+ return name.startsWith("x-plurnk-")
984
+ && !frameworkOwned.has(name)
985
+ && !updatedNames.has(name);
986
+ }),
987
+ ];
898
988
  return [
899
989
  lines[0] ?? "HTTP 200 OK",
900
990
  ...retained.map(({ line }) => line),
@@ -916,8 +1006,11 @@ class Http {
916
1006
  : null;
917
1007
  if (responseTag === null || storedTag === null)
918
1008
  return false;
919
- return responseTag.opaque === storedTag.opaque
920
- && (responseTag.weak || !storedTag.weak);
1009
+ // {§revalidation} — If-None-Match correspondence is a weak
1010
+ // comparison (RFC 9110 §8.8.3.2): opaque tags match regardless of
1011
+ // the W/ prefix, so an origin that upgrades a stored weak etag to
1012
+ // a strong etag on the 304 still corresponds.
1013
+ return responseTag.opaque === storedTag.opaque;
921
1014
  }
922
1015
  const responseModifiedValue = responseHeaders.get("last-modified");
923
1016
  const storedModifiedValues = _a.#originValues(fields, "last-modified");