@plurnk/plurnk-schemes-http 1.3.12 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.env.defaults +18 -46
  2. package/README.md +74 -23
  3. package/SPEC.md +487 -124
  4. package/dist/Config.d.ts +3 -0
  5. package/dist/Config.d.ts.map +1 -0
  6. package/dist/Config.js +18 -0
  7. package/dist/Config.js.map +1 -0
  8. package/dist/ContentType.d.ts +2 -0
  9. package/dist/ContentType.d.ts.map +1 -0
  10. package/dist/ContentType.js +15 -0
  11. package/dist/ContentType.js.map +1 -0
  12. package/dist/ErrorDetail.d.ts +6 -0
  13. package/dist/ErrorDetail.d.ts.map +1 -0
  14. package/dist/ErrorDetail.js +19 -0
  15. package/dist/ErrorDetail.js.map +1 -0
  16. package/dist/Guard.d.ts +1 -1
  17. package/dist/Guard.d.ts.map +1 -1
  18. package/dist/Guard.js +95 -55
  19. package/dist/Guard.js.map +1 -1
  20. package/dist/Http.d.ts +3 -16
  21. package/dist/Http.d.ts.map +1 -1
  22. package/dist/Http.js +817 -331
  23. package/dist/Http.js.map +1 -1
  24. package/dist/TavilyExtract.d.ts +35 -0
  25. package/dist/TavilyExtract.d.ts.map +1 -0
  26. package/dist/TavilyExtract.js +204 -0
  27. package/dist/TavilyExtract.js.map +1 -0
  28. package/dist/WebFetcher.d.ts +88 -18
  29. package/dist/WebFetcher.d.ts.map +1 -1
  30. package/dist/WebFetcher.js +588 -70
  31. package/dist/WebFetcher.js.map +1 -1
  32. package/dist/Ws.d.ts +7 -3
  33. package/dist/Ws.d.ts.map +1 -1
  34. package/dist/Ws.js +432 -96
  35. package/dist/Ws.js.map +1 -1
  36. package/dist/index.d.ts +2 -3
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +4 -10
  39. package/dist/index.js.map +1 -1
  40. package/docs/http.md +108 -53
  41. package/docs/wss.md +39 -43
  42. package/package.json +14 -13
  43. package/dist/Browser.d.ts +0 -84
  44. package/dist/Browser.d.ts.map +0 -1
  45. package/dist/Browser.js +0 -273
  46. package/dist/Browser.js.map +0 -1
  47. package/scripts/install-browser.mjs +0 -19
package/SPEC.md CHANGED
@@ -1,130 +1,493 @@
1
1
  # plurnk-schemes-http — Specification
2
2
 
3
- `http(s)://` scheme handler. Implements the `@plurnk/plurnk-schemes` author contract (SPEC §2 interface + §3.bis capability ctx). Consumed by plurnk-service via plugin discovery.
4
-
5
- ## §1 Manifest
6
-
7
- ```ts
8
- static manifest: SchemeManifest = {
9
- name: "http",
10
- // Seed defaults (pre-fetch placeholders). `body` is retyped per-call via
11
- // notifyChunk's mimetype arg. Rendered HTML projects into body while the
12
- // faithful DOM is archived in html. `header` is always text/plain.
13
- channels: { body: "application/octet-stream", header: "text/plain", html: "text/html" },
14
- defaultChannel: "body",
15
- category: "data",
16
- scope: "workspace",
17
- writableBy: ["model", "client"],
18
- volatile: true, // remote content can change between fetches
19
- modelVisible: true,
20
- flags: { requiresWeb: true }, // excluded under the loop's noWeb flag
21
- };
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.
6
+
7
+ ## §http-manifest §1 HTTP manifest
8
+
9
+ | Field | Value |
10
+ | --------------- | ----------------------------- |
11
+ | Registered name | `http` (`https` routes to it) |
12
+ | Category | `data` |
13
+ | Writers | `model`, `client` |
14
+ | Volatile | `true` |
15
+ | Model-visible | `true` |
16
+ | Requires web | `true` |
17
+ | Default channel | `body` |
18
+
19
+ | Channel | Seed type | Meaning |
20
+ | -------- | --------------------------- | ------------------------------------------------------------------------------ |
21
+ | `body` | `application/octet-stream` | Source text, derived Unicode, binary marker, SSE data, or HTML-page Markdown |
22
+ | `header` | `text/plain` | Origin, acquisition, materializer, projection, provider, and usage evidence |
23
+ | `html` | `text/html` | Original server-source HTML when an HTML representation can be acquired |
24
+
25
+ `package.json#plurnk.schemes` registers `http` through the default export and
26
+ `wss` through `Ws`.
27
+
28
+ Routing is not identity. `NetworkAddress` {§network-address} retains the exact
29
+ addressed protocol and stores
30
+ `/<host>[:<non-default-port>]<path>[?<serialized-query>]`. Query ordering,
31
+ duplicate names, and an explicit empty `?` remain significant. The fragment is
32
+ a Plurnk channel selector and never enters network identity or transport.
33
+ Request metadata affects transport but not identity. URL userinfo is rejected;
34
+ neither credentials nor metadata are reconstructed from `raw`. Within storage
35
+ pathnames, `%28` and `%29` canonicalize to literal parentheses.
36
+
37
+ ## §op-surface §2 HTTP operation surface
38
+
39
+ | Operation | Remote action | Contract |
40
+ | --------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------- |
41
+ | Exact `READ(url)<scope?>` | GET unless a fresh GET copy is usable | Prepare one complete canonical representation, then let core select and project it |
42
+ | Exact `FIND(url)` | GET only when acquisition is required | Use the same preparation, then universal query, matcher, weighting, and pagination |
43
+ | `FIND(pattern-url)` | None | Query already-materialized web entries; a path pattern does not discover the remote web |
44
+ | `SEND[200](url):body:` | POST | Stream and persist the response under the addressed URL |
45
+ | `EDIT(url):body:` | PUT | Replace the whole remote resource; a line marker is invalid |
46
+ | `KILL(url)` | DELETE | Delete the remote resource and stream its response |
47
+ | `SEND[410](url)` | None | Delete the local stored entry |
48
+ | `SEND[499](url)` | Cancellation is engine-routed | The routed subscription handle aborts the live owner; scheme dispatch is a `200` no-op |
49
+
50
+ Finite GET uses scope-blind representation preparation; POST, PUT, DELETE,
51
+ and genuinely live GET responses retain the subscription path. Request
52
+ headers are ordered target metadata: one trailing `{Key: value}` block per
53
+ header. The loop `SEND[code]` is never the remote HTTP status; remote status and
54
+ headers are persisted in `header`.
55
+ Exact-versus-pattern FIND preparation uses the shared
56
+ `PathSyntax.hasGlob` classifier {§path-glob}; HTTP owns no reduced
57
+ path-pattern grammar.
58
+
59
+ ## §http-lifecycle §3 Acquisition, materialization, and query lifecycle
60
+
61
+ ```mermaid
62
+ flowchart TD
63
+ get["GET acquisition"] --> live{"text/event-stream?"}
64
+ live -->|yes| subscription["Seed canonical channels<br/>retain subscription; return 102"]
65
+ live -->|no| origin{"Finite origin outcome"}
66
+ origin -->|text/markdown| markdown["Use origin Markdown as body<br/>and request an HTML variant"]
67
+ origin -->|server HTML| source["Retain exact server HTML"]
68
+ origin -->|transport unavailable<br/>after public admission| noSource["No HTML source"]
69
+ source --> eligible{"Generic public request<br/>with Tavily configured?"}
70
+ noSource --> eligible
71
+ 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"]
76
+ local -->|present, including empty| localBody["Local body"]
77
+ local -->|absent or no source| bodyError
78
+ markdown --> settle["Write complete canonical channels<br/>and durable producer results"]
79
+ providerBody --> settle
80
+ recovery --> settle
81
+ localBody --> settle
82
+ bodyError --> settle
83
+ settle --> core["Core selects channel<br/>and projects authored text scope"]
22
84
  ```
23
85
 
24
- `package.json#plurnk`: `{ "kind": "scheme", "name": "http" }`.
25
-
26
- `https` routes through the registered `http` handler, but routing is not identity: entries retain the addressed protocol and fold authority into their storage pathname (`https` + `/example.com/page`). Thus two hosts never collide and `http://` never aliases `https://`.
27
-
28
- Entry identity is the resolved URL path, not its grammar-safe spelling. `%28` and
29
- `%29` materialized from search or received in a model address canonicalize to
30
- literal parentheses in storage; model-facing renderers encode them again.
31
-
32
- ## §2 Op surface {§op-surface}
33
-
34
- Implemented against the DB-free `SchemeCtx` (no `ctx.db`):
35
-
36
- The HTTP method is the op: an unscoped `read` → GET, `send` (SEND[200]) → POST, `edit` → PUT (whole-body; `<L>` rejected), `kill` → DELETE. A scoped READ observes the already-materialized readable response through the standard entry operation; it never refetches and discards the requested scope. Without a materialized body it returns 409 directing an unscoped READ first. `SEND[410]` drops the cached copy; `SEND[499]` cancels in-flight; other SEND codes → 501. Request headers ride the target's `{Key: value}` blocks (grammar#46).
37
-
38
- FIND uses the schemes framework's universal data-scheme behavior rather than
39
- an HTTP-specific query implementation. For an exact URL, `prepareFind`
40
- materializes a missing entry through the guarded `WebFetcher` byte/render path;
41
- the consumer then applies its standard catalog, matcher, span, weight,
42
- pagination, and status semantics. Glob and regex scopes survey
43
- already-materialized web entries because a pattern cannot discover the remote
44
- web. FIND returns metadata and match coordinates; READ returns content.
45
-
46
- Results use the `passthrough` family (read-only / network shape) — http entries are coordinate/URL-addressed, not entry-CRUD-backed.
47
-
48
- ## §3 Streaming lifecycle
49
-
50
- All verbs share one streaming core:
51
-
52
- 1. `ctx.subscriptions.open(pathname, handle, { publishedChannel })` — registers the subscription for cancel routing and selects the one channel published to the requesting loop; returns the worker+teardown-composed `AbortSignal`. The handle's `cancel()` aborts a local `AbortController` wired to the `fetch`/render.
53
- 2. `fetch(url, { signal })` — GET (READ) or POST (SEND[200], body from `SendBody.raw`); read the response `Content-Type`.
54
- 3. **Render gate ({§render-lifecycle}):** a GET whose response is HTML routes to the render path; a GET whose response is `text/event-stream` routes to the SSE path ({§sse}); everything else (POST responses, non-HTML bodies) streams raw.
55
- 4. Response status + headers are persisted in `header`.
56
- 5. Non-HTML body is persisted in `body` under its response type. Rendered HTML → `projection.readable(finalDom, "text/html")`, then the readable result goes to `body` and the faithful DOM to `html`. A page with no readable projection fails; raw DOM never silently becomes the model-facing body.
57
- 6. `close("done", …)` on clean end; `close("error", reason)` on failure.
58
-
59
- Returns `102 Processing` on success (the subscription drives the channel content). The composed signal aborting (loop.cancel) and the local handle (SEND[499]) both tear the fetch/render down.
60
-
61
- Channel publication follows the manifest contract: a fragmentless READ publishes only `defaultChannel` (`body`) and renders under the exact fragmentless URL. An explicit fragment publishes that named channel. All channels still persist, but auxiliary transport metadata and faithful DOM do not become ambient model results merely because they were acquired alongside the readable projection. Unit and core integration coverage pin both publication filtering and durable auxiliary persistence.
62
-
63
- ## §4 Status mapping
64
-
65
- | Outcome | status |
66
- |---|---|
67
- | Stream opened (READ / SEND[200] success) | 102 |
68
- | SEND[410] delete | as `ctx.entries.delete` returns |
69
- | SEND[499] cancel | 200 (engine already routed teardown to the handle) |
70
- | Client-cancelled fetch | 499 (`kind: aborted`) |
71
- | Upstream / network failure | 502 (`kind: fetch_failed`) |
72
- | Non-url target | 400 (`kind: bad_target`) |
73
- | Uninterpreted SEND status | 501 (`kind: unsupported_send`) |
74
-
75
- Error results carry a `scheme:http` `TelemetryEvent` (via `Results.error`).
76
-
77
- ## §5 Dependencies
78
-
79
- The **byte path** is dependency-free: `fetch` / `AbortController` / `TextDecoder` / `ReadableStream` are Node ≥25 built-ins.
80
-
81
- The **render path** takes one runtime dependency, `playwright`, **lazy-imported** (`Browser.ts`) so byte fetches do not load it. A normal installation provisions Playwright's compatible Chromium hermetically inside the installed package (`PLAYWRIGHT_BROWSERS_PATH=0`) and boot verifies it before reporting the daemon ready; execution therefore does not depend on the installing user's home-directory cache. Playwright's native installation environment, including an operator-set `PLAYWRIGHT_BROWSERS_PATH` or `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD`, remains authoritative.
82
-
83
- `PLURNK_SCHEMES_HTTP_PLAYWRIGHT_METHOD` maps directly to Playwright's BrowserType surface: `launch` (default), `connect`, `connectOverCDP`, or `disabled`. Launch uses the provisioned Chromium unless the operator selects a supported installed-browser `CHANNEL` or exact `EXECUTABLE_PATH`. `connect` uses the full Playwright protocol; `connectOverCDP` attaches to a running Chromium browser with Playwright's documented lower-fidelity CDP path. Both require `PLAYWRIGHT_ENDPOINT`. Disabled rendering fails clearly rather than treating raw HTML as equivalent. Contradictory selections fail boot; methods never fall back into one another.
84
-
85
- ## §6 Render lifecycle {§render-lifecycle}
86
-
87
- `Browser` (`export default class`, barrel-exported as a standalone foundation) is the headless-Chromium render engine — ported from rummy.web's WebFetcher, render-only.
88
-
89
- - **Gate:** a GET whose response `Content-Type` is `text/html` / `application/xhtml+xml` renders; the probe-fetch body is discarded and the browser does its own navigation. POST never renders.
90
- - **Render:** warm chromium (one per `Browser`), per-worker `BrowserContext` keyed on `ctx.workerId`, **mobile-emulated by default** (Pixel-5-class viewport + UA — responsive sites serve lighter layouts; `PLURNK_SCHEMES_HTTP_MOBILE=0` renders desktop), navigate with `waitUntil: "networkidle"` + a salvage path (timed-out-but-rendered pages with substantive body text), serialize the final DOM via `page.content()`.
91
- - **Body:** the consumer's configured mimetype family projects the serialized DOM. Its readable markdown is the decisive `body` used by READ, FIND, embeddings, weights, and the model; the faithful final DOM is archived under `html` for XPath and inspection. Direct READ and search prefetch therefore expose the same kind of model-facing content.
92
- - **Host rewrite (bounded, first-party):** {§host-rewrite} a GitHub `…/blob/…` URL is fetched as its `raw.githubusercontent.com` source (line-navigable, exact) — the blob page is a CSP-locked JS SPA and code wants source, not a rendered viewer. This is the ONLY host rewrite; Wikipedia was measured through the extractor and deliberately gets none (desktop already extracts the full clean article; rewrites regressed it — schemes-http#4).
93
- - **Config:** `.env.defaults` at the package root is the authoritative list (family-namespaced `PLURNK_SCHEMES_HTTP_*`), shipped in the tarball; the daemon assembles it into the boot floor set-if-unset (service SPEC §operator-config-env-defaults, schemes#31). Required numerics — `FETCH_TIMEOUT`, `SALVAGE_MIN_BODY_CHARS`, `IDLE_TIMEOUT`, `PLAYWRIGHT_TIMEOUT`, and `SSE_MAX_BUFFER_CHARS` — fail hard when unset (no in-code defaults). `PLAYWRIGHT_METHOD`, `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_CHROMIUM_SANDBOX`, and `MOBILE` are floor-defaulted and required reads. `PLAYWRIGHT_ENDPOINT` belongs only to `connect`/`connectOverCDP`; `PLAYWRIGHT_CHANNEL` and `PLAYWRIGHT_EXECUTABLE_PATH` belong only to `launch` and are mutually exclusive. `CHROMIUM_HEAP_MB` remains an optional local-launch control.
94
- - **Freshness (READ):** {§revalidation} one predicate (`#storedCopyServable`), two phases. Pre-fetch: a stored copy whose materialization stamp (`x-plurnk-fetched-at`, HEADER channel) is inside `PLURNK_SCHEMES_HTTP_TTL_MS` serves with zero round-trips — identical for model READs and lane-1 prefetch (#405). The shipped five-minute window makes a just-materialized search result immediately readable as an ordinary entry; `0` explicitly opts into revalidation on every read. The stamp resets only when the origin vouches (fetch or 304), never on a cache serve. Past the window a repeat READ recovers the prior fetch's validators from its own stored entry (`ETag`→`If-None-Match`, `Last-Modified`→`If-Modified-Since`) and revalidates. A `304` re-serves the stored body and **skips the render** — a first-class READ (the model sees an ordinary streaming result, never a cache status; `revalidated 304` rides the close summary). The TTL is the #333 milestone landed at that same one-predicate boundary (blessed by service#341; delivered per #405). `SEND[410]` drops the stored copy, forcing the next READ to full-fetch.
95
- - **Cancel:** the composed `AbortSignal` / SEND[499] handle aborts the render by closing the page (in-flight `goto` rejects promptly).
96
-
97
- - **SSE (READ):** {§sse} a GET whose response is `text/event-stream` is parsed by `eventsource-parser`, not streamed raw. Each event's joined `data` value dispatches as one `notifyChunk("body", …, "text/plain")`; the model reads event payloads, not framing. Comments and `event`/`id`/`retry` metadata do not enter the body. `SSE_MAX_BUFFER_CHARS` bounds an incomplete remote event; exhaustion fails the stream. Reconnection (`Last-Event-ID`) is a follow-up (#468). Events from a long-lived GET land across turns until the origin closes; the close summary counts them.
98
-
99
- ## §7 Prefetch primitive {§prefetch}
100
-
101
- `WebFetcher` is the guarded acquisition seam core's entry-materialization calls (#454):
102
-
103
- ```ts
104
- new WebFetcher().fetch(url, opts): Promise<{
105
- body: string;
106
- mimetype: string;
107
- render?: () => Promise<{ body: string; mimetype: string } | null>;
108
- } | null>
86
+ The same page producer serves exact GET preparation, exact FIND, and executor
87
+ entry acquisition. Their outer policies differ: authored GET accepts explicit
88
+ targets and HTTP error responses; automatic acquisition first admits a public
89
+ credential-free target and treats non-2xx responses as unavailable. READ scope
90
+ and channel selection never enter the producer. Freshness may avoid transport,
91
+ but cold and warm representations pass through the same core projection.
92
+
93
+ | Response | `body` | Auxiliary materialization | Completion |
94
+ | --------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------- |
95
+ | 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
+ | `text/event-stream` | One `data` value plus newline per `text/plain` chunk | Initial response in `header` | `102`; origin close settles subscription |
98
+ | No response body | Present empty text | Response and package metadata in `header` | Static representation; body READ is `204` |
99
+ | Origin HTTP `4xx`/`5xx` | Preserve available origin or independently produced text | Exact response evidence; origin-backed channels errored | Selected channel's durable outcome |
100
+ | Configured textual type | Complete Fetch-decoded Unicode | Response and package metadata in `header` | Static representation |
101
+ | Readable binary type | Derived Unicode under the projection output type | Origin type and projection identity in `header` | Static representation; bytes never durable |
102
+ | Unreadable binary or unknown bytes | No representation is fabricated | Exact non-retryable Problem | `415 binary-response-unsupported` |
103
+ | Binary input exceeds configured bound | No representation is fabricated | Exact size evidence in the Problem | `413 projection-input-limit` |
104
+
105
+ The producer persists every available channel; core publishes only the channel
106
+ selected by the fragment, or `body` by default. Broad FIND returns standard
107
+ resource metadata; exact matcher FIND returns flat match locations. Exact READ
108
+ returns the selected channel's requested text projection.
109
+
110
+ ### §http-text-decoding Text response decoding
111
+
112
+ | Surface | Contract |
113
+ | ----------------------------- | ------------------------------------------------------------------------------------------------ |
114
+ | Response media type | WHATWG `MIMEType` essence; absent or unparseable metadata becomes `application/octet-stream` |
115
+ | Finite GET text | Fetch `Response.text()` UTF-8 decoding; buffering does not select a different character encoding |
116
+ | Streamed mutation response | Incremental replacement-mode UTF-8 through `TextDecoder`, preserving response backpressure |
117
+ | `charset` parameter | Preserve in `header` as origin evidence; it does not replace Fetch text decoding |
118
+ | JSON and XML textual families | Use the same HTTP byte-to-string rule; format projections consume the resulting Unicode string |
119
+ | Direct HTML GET | Fetch UTF-8 decoding of origin server HTML |
120
+ | Server-sent events | UTF-8 only under the HTML event-stream standard |
121
+ | Malformed UTF-8 | Preserve the Encoding Standard's replacement-character behavior |
122
+
123
+ This decoder boundary remains text normalization, not a media-format processor.
124
+ A configured binary type bypasses it and enters the mimetype family's bounded
125
+ readable-byte projection {§mimetype-binary-input}; raw bytes never become a
126
+ durable channel.
127
+
128
+ ### §html-materialization Readable materialization
129
+
130
+ `WebFetcher.materialize` is the shared readable-representation seam for exact
131
+ GET/FIND preparation and executor entry acquisition. It returns complete
132
+ Unicode channel material for one atomic canonical entry write; only SSE and
133
+ mutation responses retain incremental streaming.
134
+
135
+ | Input or event | Action | Result |
136
+ | ------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------ |
137
+ | Configured non-HTML text | Decode with Fetch UTF-8 | Original representation in `body` |
138
+ | Configured binary with reader | Apply the bounded byte projection | Derived Unicode plus source type and identity |
139
+ | Configured binary without reader | Cancel without retaining bytes | `null`; the consumer applies its absence policy |
140
+ | 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 |
147
+ | Projection implementation throws | Stop and preserve the cause | `WebMaterializationError` with stage `projection` |
148
+
149
+ A projection object is present even when its content is `""`; only `null`
150
+ denotes absence. A materialization exception retains its original `cause`; it
151
+ never enters the absence channel.
152
+
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.
161
+
162
+ When no `Accept` is authored, origin acquisition offers
163
+ `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.
187
+
188
+ #### §http-channel-outcomes Channel outcomes
189
+
190
+ For finite HTML-page production, `body`, `header`, and `html` have independent
191
+ durable outcomes and are written atomically. The selected channel alone
192
+ determines the projected operation result: successful `#html` or `#header` can
193
+ be read when `body` failed, and a successful default `body` is not invalidated
194
+ by unavailable `html`. Unselected failures remain `errored` channels carrying
195
+ their exact `producerResult`. A recoverable local body remains a static readable
196
+ channel with producer status `203`, so cold and warm READ both return its
197
+ content with that same status.
198
+
199
+ Exact READ, exact FIND, and executor materialization preserve the same channel
200
+ representation. A missing source variant is an explicit empty `errored`
201
+ channel, never an absent fact that later cache use can reinterpret as successful
202
+ empty content. A direct non-success HTTP response likewise remains available as
203
+ evidence, while each origin-backed channel carries an exact
204
+ `http-response-status` producer Problem. A Tavily-produced body and the
205
+ acquisition `header` remain independent of an unavailable origin source.
206
+
207
+ ## §http-status §4 HTTP status mapping
208
+
209
+ | Outcome | Operation status |
210
+ | ------------------------------------------------------------- | -------------------------------------------------------- |
211
+ | Finite exact READ after preparation | Universal selected-channel result (`200`, `204`, or producer status) |
212
+ | Exact FIND after preparation | Exact universal query result |
213
+ | Exact acquisition returns no WebFetcher value | `404` (`not-materialized`) |
214
+ | Selected origin-backed channel received HTTP `4xx`/`5xx` | Exact durable `http-response-status` Problem |
215
+ | Selected HTML-page channel fails | That channel's exact durable producer Problem |
216
+ | Local HTML projection is absent | `422` (`no-readable-projection`) |
217
+ | Recoverable Tavily failure uses local projection | Readable body with durable status `203` |
218
+ | Finite textual or projected-binary response | Universal READ result |
219
+ | Finite empty text response | `204` |
220
+ | Finite binary response has no readable projection | `415` (`binary-response-unsupported`) |
221
+ | Binary projection input exceeds the configured byte bound | `413` (`projection-input-limit`) |
222
+ | `SEND[410]` | Exact entry-delete result |
223
+ | Routed `SEND[499]` dispatch | `200` |
224
+ | Client-cancelled finite acquisition | `499` (`cancelled`) |
225
+ | SSE after acquisition | `102` initial; terminal selected-channel result |
226
+ | SSE cancellation after acquisition | `102` initial; terminal `499` |
227
+ | SSE parser/transfer failure after acquisition | `102` initial; terminal `502` |
228
+ | Multi-statement HTTP edit batch | `409` (`non-atomic-edit-batch`) |
229
+ | Invalid target, channel, line edit, or URL userinfo | `400` with the corresponding stable Problem kind |
230
+ | Non-corresponding 304 | `502` (`fetch-failed`) |
231
+ | Acquisition failure without a successful provider body | `502` (`fetch-failed`) |
232
+ | Projection exception | `500` (`projection-failed`) |
233
+ | Uninterpreted SEND status | `501` (`send-status-unsupported`) |
234
+
235
+ An HTTP error status is still a successfully acquired direct response: its
236
+ content and response evidence are preserved, and origin-backed channels carry
237
+ 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
240
+ a non-2xx response as unavailable. Handler failures use RFC 9457 Problem Details.
241
+ Caught direct-acquisition diagnostics are bounded by
242
+ `PLURNK_SCHEMES_HTTP_ERROR_DETAIL_LIMIT` in model-facing detail while complete
243
+ errors remain in daemon diagnostics.
244
+
245
+ A binary response first asks the installed mimetype family for a bounded
246
+ readable projection. A present result stores only its derived Unicode and
247
+ appends authoritative `x-plurnk-projection-id` evidence after the origin and
248
+ acquisition fields. A finite GET with no projection returns non-retryable `415`
249
+ without fabricating a text entry; exceeding the input ceiling similarly returns
250
+ `413` with configured and observed sizes. A streamed mutation response already
251
+ owns a seeded lifecycle entry and settles its typed channel as errored instead.
252
+ These statuses describe Plurnk's materialization boundary, not the remote HTTP
253
+ outcome—a POST, PUT, or DELETE might already have changed the remote resource.
254
+ Missing or malformed `Content-Type` becomes `application/octet-stream`; the
255
+ handler does not sniff or guess unknown bytes.
256
+
257
+ ## §5 Dependencies and configuration
258
+
259
+ | Surface | Runtime contract |
260
+ | ----------------- | --------------------------------------------------------------------------------------------------------- |
261
+ | Platform | Node ≥26 native fetch, streams, abort signals, decoding, DNS, and `WebSocket` |
262
+ | SSE | `eventsource-parser` for bounded WHATWG event-stream framing |
263
+ | Tavily Extract | Optional Tavily Extract API primitive when `TAVILY_API_KEY` is present |
264
+
265
+ ### §http-config Operator configuration
266
+
267
+ | Concern | Contract |
268
+ | --------------------- | -------------------------------------------------------------------------------------------------------- |
269
+ | Canonical registry | Shipped `.env.defaults`; the daemon assembles it as a set-if-unset floor {§operator-config-env-defaults} |
270
+ | 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` |
274
+
275
+ ### §automatic-fetch-check Automatic acquisition URL check
276
+
277
+ `WebFetcher` is the sole caller of `Guard.fetch`. Before automatic byte
278
+ acquisition, it accepts credential-free HTTP(S) targets only when every
279
+ resolved address is ordinary globally reachable unicast, and repeats the check
280
+ before every manually followed redirect. A refused or unresolvable target is
281
+ the ordinary unavailable `null` result. A transport failure after public
282
+ admission may still enter Tavily page production.
283
+
284
+ Direct HTTP operations and WebSocket connections do not use this check. Loopback,
285
+ private, and link-local destinations are therefore valid explicit targets.
286
+
287
+ Redirect transitions follow WHATWG Fetch: 301/302 rewrite POST to GET; 303
288
+ rewrites methods other than GET/HEAD to GET; 307/308 preserve method and body.
289
+ A body rewrite removes body headers, cross-origin redirects remove
290
+ `Authorization`, and followed redirect bodies are cancelled. The configured
291
+ hop limit returns the last redirect rather than following beyond the limit.
292
+ Validation-time DNS answers are not pinned to connection-time resolution. This
293
+ check therefore makes no DNS-rebinding or total-egress claim.
294
+
295
+ ## §materialization-lifecycle §6 Materialization lifecycle
296
+
297
+ | Concern | Contract |
298
+ | -------------- | ---------------------------------------------------------------------------------------------------------- |
299
+ | Direct gate | Explicit targets use native origin transport; only a generic public request grants Tavily authority |
300
+ | 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 |
302
+ | Source owner | `html` is exact server-source HTML; it is never provider-generated or DOM-generated |
303
+ | Projection | A present projection, including `""`, becomes `body`; `null` alone means absence |
304
+ | Cancellation | One caller signal spans origin, auxiliary origin, projection, and Tavily work |
305
+
306
+ ### §host-rewrite Acquisition target rewrite
307
+
308
+ Only acquisition GETs are eligible for host rewriting. A GitHub
309
+ `…/blob/…` address uses the corresponding `raw.githubusercontent.com` source for
310
+ byte transport. Direct READ, exact FIND, and
311
+ WebFetcher prefetch share that rule. The originally addressed GitHub URL remains
312
+ entry identity. POST, PUT, and DELETE are never retargeted. Rewritten targets
313
+ are checked under {§automatic-fetch-check} only when `WebFetcher` acquires them.
314
+
315
+ ### §revalidation GET representation freshness
316
+
317
+ Acquired responses append package-owned `x-plurnk-request-method`,
318
+ `x-plurnk-fetched-at`, and `x-plurnk-cache-variant` fields after origin headers.
319
+ Page bodies append `x-plurnk-materializer-id`; local projections append
320
+ `x-plurnk-projection-id`; Tavily appends status, route, request, usage, timing,
321
+ and bounded failure evidence. Only package metadata after the acquisition stamp
322
+ is authoritative.
323
+ Plurnk stores one representation per canonical URL rather than a variant set:
324
+
325
+ | Acquisition context | Package variant | Later cache use |
326
+ | -------------------------------------------- | --------------- | --------------- |
327
+ | No explicit target metadata; no `Vary` | `default` | Eligible |
328
+ | Any explicit target metadata | `bypass` | Ineligible |
329
+ | No explicit target metadata; any `Vary` | `bypass` | Ineligible |
330
+ | Stored response lacks authoritative evidence | Marker absent | Ineligible |
331
+
332
+ This conservative selection avoids persisting request values or fabricating a
333
+ multi-variant store. Exact FIND passes target metadata through acquisition but
334
+ does not reuse that response later. A `304` that introduces `Vary` changes the
335
+ restored representation's package marker to `bypass`.
336
+
337
+ Only an eligible GET representation can supply a direct READ's body, TTL stamp,
338
+ conditional validators, or exact-FIND preparation. Completion comes from
339
+ channel lifecycle, not body length: `body` and `header` must be successful
340
+ (`static` or `closed`), and every channel must be terminal. An auxiliary channel
341
+ may be `errored`; `active` or unknown state is ineligible. Durable operation
342
+ evidence and HTTP reuse eligibility remain distinct: an acquired response stays
343
+ in the entry even when its origin policy prevents later cache use.
344
+
345
+ | Stored origin policy | Direct READ after acquisition | Exact FIND after acquisition |
346
+ | ------------------------------------- | ------------------------------------------------------ | ---------------------------------------- |
347
+ | `no-store` | Full acquisition without stored validators | Full acquisition |
348
+ | `no-cache` (qualified or unqualified) | Validate when a stored validator exists; else acquire | Full acquisition |
349
+ | Valid `max-age` | Serve only inside origin lifetime and operator ceiling | Reuse only while fresh under both limits |
350
+ | Valid `Expires`, without `max-age` | Same, using the origin expiration lifetime | Same |
351
+ | Invalid or ambiguous explicit expiry | Treat as stale; validate or acquire | Full acquisition |
352
+ | No explicit origin lifetime | Use the operator TTL as Plurnk's heuristic | Reuse inside the operator TTL |
353
+
354
+ The operator ceiling is `PLURNK_SCHEMES_HTTP_TTL_MS`; `0` disables every
355
+ validation-free reuse. Origin age is the greater of the response's `Age` value
356
+ and apparent age from `Date`, plus residence since the authoritative package
357
+ stamp. A representation is fresh only while both origin lifetime and operator
358
+ ceiling permit it. Unknown cache extensions are inert. Stale content is never
359
+ served, so `must-revalidate` requires no separate path.
360
+
361
+ Outside the fresh window, a representation without a materializer identity may
362
+ send a stored ETag or Last-Modified only when that field is singular and
363
+ syntactically valid. A 304 can restore those stored channels only when its
364
+ validator corresponds to the nominated stored representation:
365
+
366
+ | 304 validator | Correspondence requirement |
367
+ | ----------------------------------------------------- | ------------------------------------------- |
368
+ | Strong ETag | Same stored strong ETag |
369
+ | Weak ETag | Same opaque tag under weak comparison |
370
+ | No ETag; Last-Modified | Same valid stored Last-Modified instant |
371
+ | Missing, malformed, unsolicited, or non-corresponding | Invalid acquisition; `502` (`fetch-failed`) |
372
+
373
+ A corresponding 304 restores the stored channels without rematerializing and
374
+ updates the header under the following ownership rule; any other response
375
+ replaces the channels:
376
+
377
+ | 304 metadata class | Stored-header action |
378
+ | --------------------------------------------------------------------- | ------------------------------------------------------- |
379
+ | Present end-to-end origin fields, including cache and validators | Replace prior fields of the same case-insensitive name |
380
+ | Origin fields absent from the 304 | Preserve |
381
+ | `Content-Type`, `Content-Encoding`, `Content-Range`, `Content-Length` | Preserve metadata describing the already-processed body |
382
+ | Package method, acquisition stamp, and variant | Rebuild authoritatively; refresh stamp and variant |
383
+ | Projection evidence | Preserve |
384
+
385
+ A 304-provided `Vary`, `no-store`, `no-cache`, expiry, or validator
386
+ therefore governs the next operation without relabeling derived Unicode as a
387
+ different source representation.
388
+
389
+ A stored derived representation requires the current projection identity. A
390
+ 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.
395
+
396
+ Once a page representation leaves its fresh window, Plurnk performs complete
397
+ 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
401
+ body, TTL, and validators together.
402
+
403
+ POST, PUT, and DELETE responses retain their method marker but cannot satisfy a
404
+ later GET or exact-FIND acquisition. An unmarked authored entry and an eligible
405
+ stored GET remain visible to universal FIND as durable evidence; exact HTTP
406
+ preparation applies the policy above. `SEND[410]` deletes the stored entry.
407
+
408
+ ### §sse Server-sent events
409
+
410
+ A direct GET whose response is `text/event-stream` feeds the bounded parser.
411
+ Each event's joined `data` value plus a newline becomes one `text/plain` body
412
+ chunk. Comments and `event`, `id`, and `retry` metadata are not projected.
413
+ The response plus persisted header is the acquisition boundary: READ returns
414
+ `102`, and parsing continues through the retained `StreamSubscription` without
415
+ retaining `SchemeCtx`. Buffer exhaustion and post-acquisition transport failure
416
+ settle that subscription at `502`; cancellation settles it at `499`. The
417
+ handler does not reconnect; events accumulate until the origin closes or the
418
+ operation is cancelled.
419
+
420
+ ## §prefetch §7 WebFetcher
421
+
422
+ | Result | Meaning |
423
+ | ------------------------------------ | ---------------------------------------------------------------------------------------- |
424
+ | Origin HTML | Exact source text, MIME type, package-stamped evidence, and page-producer eligibility |
425
+ | Negotiated origin Markdown | Exact body plus independent HTML-variant outcome |
426
+ | Other accepted body | One unconsumed byte stream, MIME type, package-stamped headers, and cancellation owner |
427
+ | Admitted origin transport failure | Bounded origin failure plus eligibility for provider-only page production |
428
+ | Materialized configured text | Complete Fetch-decoded UTF-8 body |
429
+ | Materialized readable binary | Bounded derived Unicode plus source type, projection identity, and enriched header |
430
+ | Materialized HTML page | Independent body/html outcomes and complete route/provider evidence |
431
+ | Automatic top-level `null` | Refused target, non-2xx response, or unavailable response with no provider route |
432
+ | Non-page materialization `null` | No final readable binary projection |
433
+ | Caller-cancelled acquisition | Rejects with the caller signal's exact reason |
434
+
435
+ Top-level `null` is an automatic-acquisition liveness value rather than a thrown
436
+ failure. Caller cancellation is not liveness: a pre-aborted caller fails before
437
+ acquisition and a caller abort wins with its exact reason. Package-owned origin
438
+ and Tavily timeouts are ordinary classified acquisition outcomes. WebFetcher
439
+ owns no entry identity, registry selection, selected-channel policy, or query
440
+ policy; its consumers supply those boundaries and the projection capability.
441
+
442
+ ## §ws §8 WebSocket
443
+
444
+ `wss` is a first-class data scheme; `ws` routes to the same handler. WebSocket
445
+ is bidirectional and stateful, not an HTTP content type. It uses `messages` as a
446
+ `text/plain` default channel and the same canonical network address contract
447
+ {§network-address}.
448
+
449
+ ### §ws-lifecycle Socket ownership and settlement
450
+
451
+ ```mermaid
452
+ stateDiagram-v2
453
+ [*] --> Idle
454
+ Idle --> Claimed: claim canonical workspace address
455
+ Claimed --> Connecting: seed entry, open subscription, construct socket
456
+ Claimed --> Idle: setup or construction failure
457
+ Connecting --> Open: native open; messages active; READ 102
458
+ Connecting --> Settling: pre-open close/error, KILL, cancel, activation failure, or shutdown
459
+ Open --> Open: ordered inbound frame or SEND[200]
460
+ Open --> Settling: closing state, close/error, KILL, cancel, binary frame, persistence failure, or shutdown
461
+ Settling --> Idle: await retained work, close subscription, release claim
109
462
  ```
110
463
 
111
- - **Primary body** — the guarded HTTP response. Core projects HTML through
112
- the MIME handler first; a useful model-facing projection wins.
113
- - **Lazy `render()`** — present for HTML. Core invokes guarded
114
- Playwright/salvage only when the primary MIME projection is empty, then
115
- projects the rendered DOM through the same handler. Browser mutation must
116
- not replace already-useful server-rendered content (#596).
117
- - **`null`** — dead: SSRF-refused, unreachable, non-2xx, non-textual (binary pruned), or empty. Dead-ness is a **value, not a throw** — the liveness verdict core prunes on.
118
- - **SSRF guard** (`Guard`): http(s) only, no localhost, every resolved address public (RFC-reserved v4/v6 ranges blocked), with **manual per-hop redirect re-guarding**; the chromium render is guarded too via request interception. Hops capped by `PLURNK_SCHEMES_HTTP_REDIRECTS`. Residual DNS-rebinding sliver (runtime re-resolves after the check) accepted day-one.
119
- - **Textual set**: `text/*`, `application/{json,xml,xhtml+xml}`, `+json`/`+xml` suffixes.
120
-
121
- ## §8 WebSocket {§ws}
122
-
123
- `Ws` is this package's **second first-class scheme** (#468, #473): registered `wss` via `package.json` `plurnk.schemes` (`{ name: "wss", export: "Ws" }`), with the `ws` prefix riding it in core's `schemeNameOf` exactly as `https` rides `http`. WebSocket is a distinct protocol — bidirectional, stateful, full-duplex, its own URI scheme — not an http content-type (that's SSE, §sse), so it has its own manifest (🔌, `messages` channel, `docs/wss.md`) and its own directory entry the model discovers natively. Op → socket lifecycle:
124
-
125
- - **`READ(wss://…)`** — open the socket, guard the target through `Guard.isPublicUrl` (extended to `ws:`/`wss:`), seed + subscribe (create-then-subscribe, http#3), stream each inbound frame into the `messages` channel (`notifyChunk`, text/plain). Returns `102`; the op **holds until the socket closes** (mirrors the streaming lifecycle — the worker wakes on close, summary counts messages).
126
- - **`SEND[200](wss://…):msg:`** — push `msg` onto the open socket. No open socket → `409` (`kind: no_open_socket`) — READ opens the connection SEND rides.
127
- - **`SEND[499](wss://…)`** — cancel; engine routes teardown to the READ's handle (which closes the socket), scheme-level `200` no-op.
128
- - **`KILL(wss://…)`** — close the open socket (`404` if none open).
129
-
130
- **Stateless-contract exception:** every other scheme is stateless per schemes SPEC §forbidden ("no state past a handler return"). A live socket IS per-workspace state, so `Ws` holds open sockets in an **in-instance registry** across op invocations — keyed `workspace:pathname`, entries present only while the socket is open (every terminal path — close/error/KILL/cancel — removes). This is the ONE sanctioned exception, because that persistence is the whole point of a WebSocket. Day-one limits: text frames only, no reconnection, default handshake identity (custom headers pending) — all #468 follow-ups.
464
+ | Operation or event | Contract |
465
+ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
466
+ | `READ(ws(s)://…)` | Claim, seed/subscribe, construct `CONNECTING`, then return `102` after native `open` plus durable activation |
467
+ | Concurrent duplicate READ | `409` in `claimed`, `connecting`, `open`, or `settling`; cleanup releases the only claim |
468
+ | `SEND[200](ws(s)://…)` | Send only for owner `open` plus native `readyState=OPEN`; absent or non-open owner is `409`; send throw is `502` |
469
+ | `SEND[499](ws(s)://…)` | Engine-routed cancellation closes the owning READ; scheme dispatch returns `200` |
470
+ | `KILL(ws(s)://…)` | Close/cancel the claimed owner; no owner is `404`; an attempted close throw is `502` |
471
+ | Inbound frame after native `open` | Join the owner's persistence chain; the next write begins only after the preceding write succeeds |
472
+ | Binary frame after native `open` | Retain the text prefix, prune the binary and later frames, settle `415 binary-frame-unsupported`, close with private-use code `4003` |
473
+ | First inbound persistence failure | Retain the successful prefix, prune queued and later frames, and settle with `500 message-persistence-failed` |
474
+ | Socket closes before `open` | Close subscription with `502 connection-failed`; the pending READ returns that exact failure |
475
+ | Socket closes after `open` | Drain the accepted frame prefix; initial READ remains `102`; settle with `200` unless a drained write fails |
476
+ | Failure after `open` | Initial READ remains `102`; persist the exact terminal failure and wake through subscription settlement |
477
+
478
+ The in-instance registry is keyed by workspace, addressed protocol, and
479
+ canonical network pathname. The claim remains registered through terminal
480
+ cleanup, so a new READ cannot overlap an owner's subscription settlement. Every
481
+ terminal path drains retained owner work, closes the transport when necessary,
482
+ closes the durable subscription, then releases the claim. A persistence failure
483
+ found while draining supersedes a graceful terminal result; cancellation and
484
+ transport failures retain their exact result. Handler shutdown requests
485
+ settlement for every remainder, awaits every owner, and aggregates
486
+ transport-close failures under {§handler-lifecycle}.
487
+
488
+ | Transport limit | Current contract |
489
+ | ------------------ | ------------------------------------------------------------------------------------------------------- |
490
+ | Payload projection | String event data only into `text/plain`; binary data terminates with Plurnk `415` and private-use WebSocket code `4003` |
491
+ | Reconnection | None; READ again after terminal cleanup |
492
+ | Handshake metadata | Default global-WebSocket identity; custom target headers are not applied |
493
+ | Runtime | Node ≥26 |