@plurnk/plurnk-schemes-http 1.3.12 → 1.4.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
@@ -1,57 +1,151 @@
1
- // http(s):// scheme handler — the first greenfield `@plurnk/plurnk-schemes-*`
2
- // sibling, authored entirely against the stable SchemeCtx contract. It never
3
- // imports private service modules or depends on database layout.
4
- //
5
- // Surface — the HTTP method is the OP (grammar#46): READ→GET, SEND→POST,
6
- // EDIT→PUT, KILL→DELETE. Every request streams its response the same way
7
- // (102 Processing now; the subscription accumulates; the model reads next turn).
8
- // READ(http(s)://host/path) — GET. HTML is rendered; else raw bytes stream.
9
- // SEND[200](http(s)://...) — POST the body; response streams back.
10
- // EDIT(http(s)://...):body: — PUT the body (full-resource replace; no `<L>`).
11
- // KILL(http(s)://...) — DELETE the resource.
12
- // SEND[499](http(s)://...) — cancel an in-flight request (abort the fetch).
13
- // SEND[410](http(s)://...) — delete the locally cached response entry
14
- // (loop disposition, NOT an HTTP DELETE — that's KILL).
15
- //
16
- // Request headers ride IN the target as trailing `{Key: value}` blocks
17
- // (grammar#46 — `UrlPath.headers`, ordered pairs), one header per block:
18
- // READ(https://api.x/v1{Authorization: Bearer T}{Accept: application/json})
19
- // The SEND `[code]` is loop disposition (102/200/…), never the HTTP status —
20
- // the real 2xx/4xx comes back in the response `header`/`body` channels.
21
- //
22
- // `fetch` is the scheme's purpose. Node owns acquisition; eventsource-parser
23
- // owns the WHATWG event-stream framing used by SSE responses.
1
+ // HTTP(S) handler. Operation-to-method semantics live in {§op-surface};
2
+ // acquisition, materialization, publication, and query flow live in
3
+ // {§http-lifecycle}. The implementation depends only on SchemeCtx capabilities.
24
4
  var _a;
25
5
  import { createParser } from "eventsource-parser";
26
- import { Results } from "@plurnk/plurnk-schemes";
6
+ import { MimetypeClassifier, NetworkAddress, PathSyntax, ProjectionInputLimitError, Results, } from "@plurnk/plurnk-schemes";
27
7
  import { readFile } from "node:fs/promises";
28
8
  import Browser, { BROWSER_UA, requireNumEnv } from "./Browser.js";
29
- import WebFetcher from "./WebFetcher.js";
9
+ import ErrorDetail from "./ErrorDetail.js";
10
+ import WebFetcher, { CACHE_VARIANT_HEADER, PROJECTION_ID_HEADER, cacheVariantEvidence, classifyCacheVariant, rewriteAcquisitionTarget, WebMaterializationError, } from "./WebFetcher.js";
11
+ import { responseMimetype } from "./ContentType.js";
30
12
  // The channel the response body streams into, and the header metadata channel.
31
13
  const BODY = "body";
32
14
  const HEADER = "header";
33
- // Materialization stamp line in the HEADER channel — the ONE timestamp the
34
- // freshness predicate reads (SPEC §revalidation). Namespaced so it can never
35
- // collide with a real response header.
15
+ // Package-owned metadata appended after untrusted origin headers. Readers take
16
+ // the last value so an origin using the same field name cannot override it.
36
17
  const FETCHED_AT = "x-plurnk-fetched-at";
37
- // Deep doc lives in `docs/http.md` (the constellation's docs/<name>.md
38
- // convention) and is loaded into the manifest at module init — the contract
39
- // field stays a plain string; only the authoring source moves out of line.
40
- // `../docs/http.md` resolves identically from src/ (test) and dist/ (published):
41
- // both sit one level under the package root. Missing file → fail-hard at import.
18
+ const REQUEST_METHOD = "x-plurnk-request-method";
19
+ const DELTA_SECONDS_LIMIT = 2147483648n;
20
+ const BODY_PROCESSING_FIELDS = new Set([
21
+ "content-encoding",
22
+ "content-length",
23
+ "content-range",
24
+ "content-type",
25
+ ]);
26
+ const splitHttpList = (value) => {
27
+ const members = [];
28
+ let start = 0;
29
+ let quoted = false;
30
+ let escaped = false;
31
+ for (let index = 0; index < value.length; index += 1) {
32
+ const character = value[index];
33
+ if (quoted) {
34
+ if (escaped)
35
+ escaped = false;
36
+ else if (character === "\\")
37
+ escaped = true;
38
+ else if (character === '"')
39
+ quoted = false;
40
+ }
41
+ else if (character === '"') {
42
+ quoted = true;
43
+ }
44
+ else if (character === ",") {
45
+ members.push(value.slice(start, index).trim());
46
+ start = index + 1;
47
+ }
48
+ }
49
+ members.push(value.slice(start).trim());
50
+ return members.filter((member) => member.length > 0);
51
+ };
52
+ const cacheDirective = (member) => {
53
+ const equals = member.indexOf("=");
54
+ const name = member.slice(0, equals < 0 ? undefined : equals).trim().toLowerCase();
55
+ if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name))
56
+ return null;
57
+ if (equals < 0)
58
+ return { name };
59
+ return { name, argument: member.slice(equals + 1).trim() };
60
+ };
61
+ const deltaMilliseconds = (argument) => {
62
+ if (argument === undefined)
63
+ return null;
64
+ const match = /^(?:([0-9]+)|"([0-9]+)")$/.exec(argument);
65
+ const digits = match?.[1] ?? match?.[2];
66
+ if (digits === undefined)
67
+ return null;
68
+ const seconds = BigInt(digits);
69
+ return Number(seconds > DELTA_SECONDS_LIMIT ? DELTA_SECONDS_LIMIT : seconds) * 1000;
70
+ };
71
+ const lastHeaderValue = (header, name) => {
72
+ const lines = header.split(/\r?\n/);
73
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
74
+ const line = lines[index];
75
+ const colon = line.indexOf(":");
76
+ if (colon < 0 || line.slice(0, colon).trim().toLowerCase() !== name)
77
+ continue;
78
+ return line.slice(colon + 1).trim();
79
+ }
80
+ return undefined;
81
+ };
82
+ const entityTag = (value) => {
83
+ const match = /^(W\/)?"([\x21\x23-\x7e\x80-\xff]*)"$/.exec(value);
84
+ return match === null
85
+ ? null
86
+ : { weak: match[1] !== undefined, opaque: match[2] };
87
+ };
88
+ const MONTHS = [
89
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
90
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
91
+ ];
92
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
93
+ const IMF_FIXDATE = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), ([0-9]{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([0-9]{4}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/;
94
+ const RFC850_DATE = /^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), ([0-9]{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-([0-9]{2}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/;
95
+ const ASCTIME_DATE = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: ([0-9])|([0-9]{2})) ([0-9]{2}):([0-9]{2}):([0-9]{2}) ([0-9]{4})$/;
96
+ const utcHttpDate = (weekday, day, month, year, hour, minute, second) => {
97
+ const monthIndex = MONTHS.indexOf(month);
98
+ const weekdayIndex = WEEKDAYS.indexOf(weekday.slice(0, 3));
99
+ const numericDay = Number(day);
100
+ const numericHour = Number(hour);
101
+ const numericMinute = Number(minute);
102
+ const numericSecond = Number(second);
103
+ if (monthIndex < 0 || weekdayIndex < 0)
104
+ return null;
105
+ const date = new Date(0);
106
+ date.setUTCFullYear(year, monthIndex, numericDay);
107
+ date.setUTCHours(numericHour, numericMinute, numericSecond, 0);
108
+ return date.getUTCFullYear() === year
109
+ && date.getUTCMonth() === monthIndex
110
+ && date.getUTCDate() === numericDay
111
+ && date.getUTCHours() === numericHour
112
+ && date.getUTCMinutes() === numericMinute
113
+ && date.getUTCSeconds() === numericSecond
114
+ && date.getUTCDay() === weekdayIndex
115
+ ? date.getTime()
116
+ : null;
117
+ };
118
+ const httpDate = (value, now = Date.now()) => {
119
+ const preferred = IMF_FIXDATE.exec(value);
120
+ if (preferred !== null) {
121
+ return utcHttpDate(preferred[1], preferred[2], preferred[3], Number(preferred[4]), preferred[5], preferred[6], preferred[7]);
122
+ }
123
+ const rfc850 = RFC850_DATE.exec(value);
124
+ if (rfc850 !== null) {
125
+ let year = 2000 + Number(rfc850[4]);
126
+ if (year > new Date(now).getUTCFullYear() + 50)
127
+ year -= 100;
128
+ return utcHttpDate(rfc850[1], rfc850[2], rfc850[3], year, rfc850[5], rfc850[6], rfc850[7]);
129
+ }
130
+ const asctime = ASCTIME_DATE.exec(value);
131
+ return asctime === null
132
+ ? null
133
+ : utcHttpDate(asctime[1], asctime[3] ?? asctime[4], asctime[2], Number(asctime[8]), asctime[5], asctime[6], asctime[7]);
134
+ };
135
+ // The package-shipped model teaching is loaded verbatim and fails hard if absent.
136
+ // The relative path is identical from src/ during development and dist/ after build.
42
137
  const documentation = await readFile(new URL("../docs/http.md", import.meta.url), "utf-8");
43
138
  class Http {
44
139
  static manifest = {
45
140
  name: "http",
46
141
  // Channel mimetypes here are SEED DEFAULTS (pre-fetch placeholders).
47
142
  // body is retyped per-call via notifyChunk's mimetype arg — to the real
48
- // response Content-Type, or projected markdown for a rendered page; octet-stream
49
- // is the honest "unknown until fetched". header is always the status
143
+ // response Content-Type or the configured readable projection type;
144
+ // octet-stream is the honest "unknown until fetched". header is always the status
50
145
  // line + headers (text/plain).
51
146
  channels: { [BODY]: "application/octet-stream", [HEADER]: "text/plain", html: "text/html" },
52
147
  defaultChannel: BODY,
53
148
  category: "data",
54
- scope: "workspace",
55
149
  writableBy: ["model", "client"],
56
150
  volatile: true, // remote content can change between fetches
57
151
  modelVisible: true,
@@ -65,9 +159,11 @@ class Http {
65
159
  // The render foundation (lazy chromium). Injectable for tests; one warm
66
160
  // pool per Http instance, shared across this scheme's fetches.
67
161
  #browser;
162
+ #errorDetailLimit;
68
163
  #webFetcher;
69
164
  constructor(browser = new Browser()) {
70
165
  this.#browser = browser;
166
+ this.#errorDetailLimit = ErrorDetail.configuredLimit();
71
167
  this.#webFetcher = new WebFetcher(browser);
72
168
  }
73
169
  async ready() {
@@ -80,103 +176,161 @@ class Http {
80
176
  }
81
177
  // FIND itself is the consumer's standard entry query. This hook owns only
82
178
  // HTTP's prerequisite: an exact URL that is not already an entry must be
83
- // acquired through the same guarded byte/render path used by search
84
- // prefetch. Glob and regex targets only query already-known web entries.
179
+ // acquired through the same checked byte and lazy-render path used by search
180
+ // prefetch. Glob targets only query already-known web entries.
85
181
  async prepareFind(statement, ctx) {
86
182
  const target = statement.target;
87
183
  if (target === null || target.kind !== "url")
88
184
  return { shape: "passthrough", status: 200 };
89
- const pathname = _a.#pathname(target);
90
- const prior = await ctx.entries.read(pathname);
91
- if (prior.entry !== null)
185
+ const address = _a.#address(target);
186
+ if (!(address instanceof NetworkAddress))
187
+ return address;
188
+ const { pathname, url } = address;
189
+ const requestHeaders = target.headers ?? [];
190
+ if (PathSyntax.hasGlob(target.pathname))
92
191
  return { shape: "passthrough", status: 200 };
93
- const fetched = await this.#webFetcher.fetch(_a.#urlFrom(target), { signal: ctx.signal });
94
- if (fetched === null) {
95
- return _a.#bad(404, "http", "not_materialized", "the requested URL could not be materialized");
192
+ 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;
200
+ try {
201
+ reusable = await _a.#reusableGetRepresentation(prior.entry, requestHeaders, ctx.projection);
202
+ }
203
+ catch (cause) {
204
+ return _a.#materializationFailure(url, "GET", new WebMaterializationError("projection", _a.#sourceMimetype(priorHeader), cause));
205
+ }
206
+ if (reusable && _a.#fresh(priorHeader)) {
207
+ return { shape: "passthrough", status: 200 };
208
+ }
96
209
  }
97
- let { body, mimetype } = fetched;
98
- let channels = {
99
- [BODY]: { content: body, mimetype },
100
- ...(fetched.header === undefined ? {} : { [HEADER]: { content: fetched.header, mimetype: "text/plain" } }),
101
- };
102
- if (mimetype === "text/html") {
103
- let projected = await ctx.projection.readable(body, mimetype);
104
- if ((projected === null || projected.content.length === 0) && fetched.render !== undefined) {
105
- const rendered = await fetched.render();
106
- if (rendered !== null) {
107
- body = rendered.body;
108
- mimetype = rendered.mimetype;
109
- projected = await ctx.projection.readable(body, mimetype);
110
- }
210
+ if (Results.isErrorStatus(prior.status) && prior.status !== 404) {
211
+ return _a.#passthrough(prior);
212
+ }
213
+ // {§prefetch} WebFetcher preserves caller cancellation as rejection;
214
+ // this operation owns its one model-facing 499 projection.
215
+ let fetched;
216
+ try {
217
+ fetched = await this.#webFetcher.fetch(url, {
218
+ signal: ctx.signal,
219
+ headers: requestHeaders,
220
+ });
221
+ }
222
+ catch (err) {
223
+ if (ctx.signal?.aborted === true && err === ctx.signal.reason) {
224
+ return _a.#cancelled(url, "GET");
111
225
  }
112
- if (projected === null || projected.content.length === 0) {
113
- return _a.#bad(422, "http", "no_readable_projection", "the requested URL has no readable projection");
226
+ throw err;
227
+ }
228
+ if (fetched === null) {
229
+ return _a.#bad(404, "http", "not-materialized", `The URL ${url} could not be materialized.`, {
230
+ target: url,
231
+ stage: "acquisition",
232
+ retryable: true,
233
+ });
234
+ }
235
+ let materialized;
236
+ try {
237
+ materialized = await WebFetcher.materialize(fetched, ctx.projection);
238
+ }
239
+ catch (err) {
240
+ if (ctx.signal?.aborted === true)
241
+ return _a.#cancelled(url, "GET");
242
+ if (err instanceof WebMaterializationError) {
243
+ return _a.#materializationFailure(url, "GET", err);
114
244
  }
115
- channels = {
116
- [BODY]: { content: projected.content, mimetype: projected.mimetype },
117
- html: { content: body, mimetype },
118
- ...(fetched.header === undefined ? {} : { [HEADER]: { content: fetched.header, mimetype: "text/plain" } }),
119
- };
245
+ throw err;
120
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
+ };
121
256
  const written = await ctx.entries.write(pathname, { channels, tags: [] });
122
- return { shape: "passthrough", status: written.status };
257
+ return _a.#passthrough(written);
123
258
  }
124
- // An unscoped READ fetches/revalidates. A scoped READ observes the already
125
- // materialized readable entry: refetching would discard the requested range
126
- // and return another asynchronous whole-body 102.
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.
127
262
  async read(statement, ctx) {
128
263
  if (statement.target === null || statement.target.kind !== "url") {
129
- return _a.#bad(400, "http", "bad_target", "READ requires an http(s):// URL target");
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.",
267
+ retryable: false,
268
+ });
130
269
  }
131
270
  if (statement.lineMarker !== null) {
132
- const prior = await ctx.entries.read(_a.#pathname(statement.target));
133
- const body = prior.entry?.channels[BODY];
134
- if (body === undefined || body.content.length === 0) {
135
- return _a.#bad(409, "http", "scope_requires_materialization", "scoped READ requires a materialized response; READ the URL without <scope> first");
136
- }
137
- const { error, ...read } = await ctx.entries.operations.read(statement);
138
- if (read.status >= 400) {
139
- return _a.#bad(read.status, "http", "scoped_read_failed", error ?? read.reason ?? `scoped READ failed with status ${read.status}`);
140
- }
141
- return { shape: "passthrough", ...read };
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));
142
275
  }
143
276
  return this.#fetchStream(statement.target, ctx, "GET", undefined);
144
277
  }
145
- // EDIT → PUT the body (full-resource replace). `<L>` has no meaning against a
146
- // remote resource — reject rather than silently ignore the model's intent.
278
+ // EDIT -> PUT the body (full-resource replace). `<L>` has no meaning against a
279
+ // remote resource - reject rather than silently ignore the model's intent.
147
280
  async editBatch(statements, ctx) {
148
281
  if (statements.length !== 1) {
149
- return _a.#bad(409, "http", "non_atomic_edit_batch", "multiple EDITs of one remote resource cannot be committed atomically");
282
+ return _a.#bad(409, "http", "non-atomic-edit-batch", `${statements.length} EDIT statements cannot be committed atomically to one remote resource.`, {
283
+ statements: statements.length,
284
+ stage: "mutation",
285
+ recovery: "Submit one whole-resource EDIT.",
286
+ retryable: false,
287
+ });
150
288
  }
151
289
  const statement = statements[0];
152
290
  if (statement.target === null || statement.target.kind !== "url") {
153
- return _a.#bad(400, "http", "bad_target", "EDIT requires an http(s):// URL target");
291
+ return _a.#bad(400, "http", "bad-target", "EDIT requires an http(s):// URL target.", {
292
+ stage: "target-validation",
293
+ recovery: "Provide an http(s):// URL target.",
294
+ retryable: false,
295
+ });
154
296
  }
155
297
  if (statement.lineMarker !== null) {
156
- return _a.#bad(400, "http", "no_line_edit", "EDIT on http PUTs the whole body; <L> line-editing a remote resource is unsupported");
298
+ return _a.#bad(400, "http", "line-edit-unsupported", "HTTP EDIT replaces the whole remote resource and does not accept a line range.", {
299
+ stage: "mutation",
300
+ recovery: "Remove the line range and submit the complete replacement body.",
301
+ retryable: false,
302
+ });
157
303
  }
158
304
  return this.#fetchStream(statement.target, ctx, "PUT", statement.body ?? "");
159
305
  }
160
306
  async edit(statement, ctx) {
161
307
  return this.editBatch([statement], ctx);
162
308
  }
163
- // KILL → DELETE the resource. Distinct from SEND[410] (which drops the local
309
+ // KILL -> DELETE the resource. Distinct from SEND[410] (which drops the local
164
310
  // cached entry): KILL is an HTTP DELETE request to the remote.
165
311
  async kill(statement, ctx) {
166
312
  if (statement.target === null || statement.target.kind !== "url") {
167
- return _a.#bad(400, "http", "bad_target", "KILL requires an http(s):// URL target");
313
+ return _a.#bad(400, "http", "bad-target", "KILL requires an http(s):// URL target.", {
314
+ stage: "target-validation",
315
+ recovery: "Provide an http(s):// URL target.",
316
+ retryable: false,
317
+ });
168
318
  }
169
319
  return this.#fetchStream(statement.target, ctx, "DELETE", statement.body ?? undefined);
170
320
  }
171
- // SEND dispatch — status-code-as-verb (SPEC §op-surface).
172
- // 200 → request with body (POST), stream response
173
- // 410 → delete the cached entry
174
- // 499 → cancel in-flight (handled by the subscription's force-cancel;
321
+ // SEND dispatch — status-code-as-verb (SPEC {§op-surface}).
322
+ // 200 -> request with body (POST), stream response
323
+ // 410 -> delete the cached entry
324
+ // 499 -> cancel in-flight (handled by the subscription's force-cancel;
175
325
  // the engine routes 499 to the registered SubscriptionHandle, so a
176
326
  // scheme-level no-op here is correct — teardown already happened)
177
327
  async send(statement, ctx) {
178
328
  if (statement.target === null || statement.target.kind !== "url") {
179
- return _a.#bad(400, "http", "bad_target", "SEND requires an http(s):// URL target");
329
+ return _a.#bad(400, "http", "bad-target", "SEND requires an http(s):// URL target.", {
330
+ stage: "target-validation",
331
+ recovery: "Provide an http(s):// URL target.",
332
+ retryable: false,
333
+ });
180
334
  }
181
335
  const status = statement.signal;
182
336
  if (status === 200) {
@@ -184,8 +338,10 @@ class Http {
184
338
  return this.#fetchStream(statement.target, ctx, "POST", body);
185
339
  }
186
340
  if (status === 410) {
187
- const { status: delStatus } = await ctx.entries.delete(_a.#pathname(statement.target));
188
- return { shape: "passthrough", status: delStatus };
341
+ const address = _a.#address(statement.target);
342
+ if (!(address instanceof NetworkAddress))
343
+ return address;
344
+ return _a.#passthrough(await ctx.entries.delete(address.pathname));
189
345
  }
190
346
  if (status === 499) {
191
347
  // Cancellation is routed by the engine to the subscription's
@@ -194,76 +350,87 @@ class Http {
194
350
  return { shape: "passthrough", status: 200 };
195
351
  }
196
352
  // Entry-bearing schemes return 501 for status codes they don't interpret.
197
- return _a.#bad(501, "http", "unsupported_send", `SEND[${status}] not supported by http`);
198
- }
199
- // The streaming core, shared by every verb. Opens the subscription
200
- // (registering the abort handle for SEND[499] routing), fetches, then EITHER
201
- // renders (a GET of an HTML page is re-acquired through the browser, its
202
- // readable projection becomes body, and its final DOM is archived) OR streams the raw bytes (every non-GET
203
- // response and every non-HTML body). Request headers from the target's `{…}`
204
- // blocks (grammar#46) ride into both the fetch and the render. Each chunk is
205
- // labelled with its real mimetype via notifyChunk. Settles via close().
353
+ return _a.#bad(501, "http", "send-status-unsupported", `The HTTP scheme does not interpret SEND status ${status}.`, {
354
+ requestedStatus: status,
355
+ stage: "dispatch",
356
+ retryable: false,
357
+ });
358
+ }
359
+ // {§http-lifecycle} One direct-operation path owns seeding,
360
+ // subscription cancellation, response materialization, and settlement.
206
361
  async #fetchStream(target, ctx, method, body) {
207
- const url = _a.#rewriteHostileHost(_a.#urlFrom(target));
208
- const pathname = _a.#pathname(target);
209
- const headers = target.headers ?? []; // [key,value][] — opaque to grammar, honored here
362
+ const address = _a.#address(target);
363
+ if (!(address instanceof NetworkAddress))
364
+ return address;
365
+ const url = method === "GET" ? rewriteAcquisitionTarget(address.url) : address.url;
366
+ const { pathname } = address;
367
+ const headers = target.headers ?? [];
210
368
  const publishedChannel = target.fragment ?? _a.manifest.defaultChannel;
211
369
  if (!(publishedChannel in _a.manifest.channels)) {
212
- return _a.#bad(400, "http", "unknown_channel", `no channel #${publishedChannel}; channels: ${Object.keys(_a.manifest.channels).join(", ")}`);
213
- }
214
- // Conditional revalidation (GET only, service#341): recover the prior
215
- // fetch's validators + body from THIS scheme's own stored entry (entries
216
- // cap, own namespace — sanctioned). If the origin answers 304, we re-serve
217
- // the cached body and skip the expensive render. Freshness is decided ONLY
218
- // by #storedCopyServable (SPEC §revalidation): within the operator TTL
219
- // window the stored copy serves with zero round-trips; past it, the
220
- // conditional GET revalidates (service#333/#405 landed the TTL). Captured
221
- // BEFORE the seed write below overwrites the entry.
370
+ const availableChannels = Object.keys(_a.manifest.channels);
371
+ return _a.#bad(400, "http", "channel-not-found", `Channel #${publishedChannel} does not exist on HTTP responses.`, {
372
+ requestedChannel: publishedChannel,
373
+ availableChannels,
374
+ recovery: `Use one of the available channels: ${availableChannels.map((channel) => `#${channel}`).join(", ")}.`,
375
+ retryable: false,
376
+ });
377
+ }
378
+ // {§revalidation} — recover only a GET-marked representation and its
379
+ // validators before the seed write replaces the stored channels.
222
380
  let cached;
223
381
  const conditional = [];
224
382
  if (method === "GET") {
225
383
  const prior = await ctx.entries.read(pathname);
384
+ if (Results.isErrorStatus(prior.status) && prior.status !== 404) {
385
+ return _a.#passthrough(prior);
386
+ }
226
387
  const pb = prior.entry?.channels[BODY];
227
- if (pb !== undefined && pb.content.length > 0) {
228
- const ph = prior.entry?.channels[HEADER]?.content ?? "";
229
- const html = prior.entry?.channels.html;
230
- cached = {
231
- header: ph,
232
- body: { content: pb.content, mimetype: pb.mimetype },
233
- ...(html === undefined || html.content.length === 0 ? {} : { html: { content: html.content, mimetype: html.mimetype } }),
234
- };
235
- conditional.push(..._a.#validators(ph));
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
+ }
236
404
  }
237
405
  }
238
- // TTL fast path (the pre-fetch phase of the ONE predicate): a stored copy
239
- // still inside the freshness window serves with NO round-trip at all —
240
- // identical for a model READ and lane-1 prefetch (#405). Stamp unchanged:
241
- // the clock only resets when the ORIGIN vouches (fetch or 304), never by
242
- // serving from cache.
243
- if (cached !== undefined && _a.#storedCopyServable(_a.#fetchedAt(cached.header), null)) {
244
- await ctx.entries.write(pathname, _a.#seedEntry());
245
- await ctx.subscriptions.open(pathname, { cancel: () => { } }, { publishedChannel });
246
- await ctx.subscriptions.notifyChunk(HEADER, cached.header, "text/plain");
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");
247
414
  if (cached.html !== undefined)
248
- await ctx.subscriptions.notifyChunk("html", cached.html.content, cached.html.mimetype);
249
- await ctx.subscriptions.notifyChunk(BODY, cached.body.content, cached.body.mimetype);
250
- await ctx.subscriptions.close("done", `ttl-fresh; ${cached.body.content.length} chars from cache`);
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`);
251
418
  return { shape: "passthrough", status: 102 };
252
419
  }
253
420
  // Local AbortController for force-cancel from outside (SEND[499]).
254
421
  const local = new AbortController();
255
422
  const handle = { cancel: () => local.abort() };
256
- // Materialize the streaming target BEFORE subscribing (http#3). open()
257
- // binds an EXISTING entry — only the scheme knows its channel shape, so
258
- // it seeds them. Mirror exec's create-then-subscribe: write a seed entry
259
- // whose channels are the manifest's (body: octet-stream placeholder,
260
- // header: text/plain) — the same channels notifyChunk then populates.
261
- await ctx.entries.write(pathname, _a.#seedEntry());
423
+ // {§http-lifecycle} open() binds an existing entry, so the handler seeds
424
+ // its manifest-owned channel shape before subscribing.
425
+ const written = await ctx.entries.write(pathname, _a.#seedEntry());
426
+ if (Results.isErrorStatus(written.status))
427
+ return _a.#passthrough(written);
262
428
  // open() returns the worker+teardown-composed signal — fires on loop.cancel
263
429
  // OR our local teardown. Wire it so either path aborts the fetch/render.
264
- const composed = await ctx.subscriptions.open(pathname, handle, { publishedChannel });
430
+ const subscription = await ctx.subscriptions.open(pathname, handle, { publishedChannel });
265
431
  const onAbort = () => local.abort();
266
- composed.addEventListener("abort", onAbort, { once: true });
432
+ subscription.addEventListener("abort", onAbort, { once: true });
433
+ let detached = false;
267
434
  try {
268
435
  const response = await fetch(url, {
269
436
  method,
@@ -277,103 +444,239 @@ class Http {
277
444
  signal: local.signal,
278
445
  redirect: "follow",
279
446
  });
280
- // Origin confirms the cached copy is current → re-serve it, skip the
281
- // render/stream. A 304-serve is a first-class READ: the model sees the
282
- // stored body as an ordinary streaming result, never a cache status
283
- // (service#341). `revalidated 304` rides the close summary for the log
284
- // meta line + digest. notifyChunk appends onto the freshly-seeded empty
285
- // channels, restoring the cached header + body.
286
- if (cached !== undefined && _a.#storedCopyServable(_a.#fetchedAt(cached.header), response)) {
287
- await ctx.subscriptions.notifyChunk(HEADER, _a.#stamp(cached.header), "text/plain");
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");
288
455
  if (cached.html !== undefined)
289
- await ctx.subscriptions.notifyChunk("html", cached.html.content, cached.html.mimetype);
290
- await ctx.subscriptions.notifyChunk(BODY, cached.body.content, cached.body.mimetype);
291
- await ctx.subscriptions.close("done", `revalidated 304; ${cached.body.content.length} chars from cache`);
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`);
292
459
  return { shape: "passthrough", status: 102 };
293
460
  }
294
- const contentType = response.headers.get("content-type") ?? "";
461
+ const responseMime = responseMimetype(response.headers.get("content-type"));
295
462
  // Always-render: a GET of an HTML page is re-acquired through the
296
463
  // browser so the body is projected from the final rendered DOM. The probe-fetch
297
464
  // body is discarded — the browser does its own navigation. Only GET
298
465
  // renders; POST/PUT/DELETE can't be replayed as a browser navigation.
299
- const isHtml = method === "GET"
300
- && /^(?:text\/html|application\/xhtml\+xml)\b/i.test(contentType);
466
+ const isHtml = method === "GET" && MimetypeClassifier.isHtml(responseMime);
301
467
  if (isHtml) {
302
468
  await response.body?.cancel();
303
- const result = await this.#browser.render(url, { workerId: ctx.workerId, signal: local.signal, headers });
304
- const projected = await ctx.projection.readable(result.html, "text/html");
305
- if (projected === null)
306
- throw new Error("rendered HTML has no readable projection");
307
- await _a.#writeHeader(ctx, result.status, result.statusText, result.headers);
308
- await ctx.subscriptions.notifyChunk("html", result.html, "text/html");
309
- await ctx.subscriptions.notifyChunk(BODY, projected.content, projected.mimetype);
310
- await ctx.subscriptions.close("done", `rendered HTTP ${result.status}; ${projected.content.length} readable chars`);
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`);
311
493
  return { shape: "passthrough", status: 102 };
312
494
  }
313
- // SSE: an event stream is parsed into its `data` payloads (SPEC §sse),
495
+ // SSE: an event stream is parsed into its `data` payloads (SPEC {§sse}),
314
496
  // one notifyChunk per event with the `data:`/comment framing stripped,
315
497
  // so the model reads event content and not the transport. A long-lived
316
498
  // GET; events land in the body channel as they arrive across turns,
317
499
  // until the origin closes. Only GET — a POST reply is never an SSE READ.
318
- if (method === "GET" && /^text\/event-stream\b/i.test(contentType)) {
319
- await _a.#writeHeader(ctx, response.status, response.statusText, [...response.headers]);
320
- return await _a.#streamEvents(ctx, response);
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 };
321
516
  }
322
- // Byte path: stream the body labelled with its real content type.
323
- await _a.#writeHeader(ctx, response.status, response.statusText, [...response.headers]);
324
- const bodyMime = contentType.split(";")[0].trim() || "application/octet-stream";
517
+ // {§http-lifecycle}/{§mimetype-classifier} String channels retain
518
+ // textual response data. Binary input is transient: an installed
519
+ // reader may derive Unicode, otherwise the durable body is a typed
520
+ // empty marker rather than a fabricated byte channel.
521
+ await _a.#writeHeader(subscription, method, response.status, response.statusText, [...response.headers], headers);
522
+ const bodyMime = responseMime;
325
523
  if (response.body === null) {
326
- await ctx.subscriptions.close("done", `HTTP ${response.status}; empty body`);
524
+ await subscription.close({ status: 200 }, `HTTP ${response.status}; empty body`);
327
525
  return { shape: "passthrough", status: 102 };
328
526
  }
527
+ const responseBody = response.body;
528
+ const byteBody = {
529
+ chunks: responseBody,
530
+ cancel: () => responseBody.cancel(),
531
+ };
532
+ const binary = await WebFetcher.classifyBinary(byteBody, bodyMime, ctx.projection);
533
+ if (binary) {
534
+ let projected;
535
+ try {
536
+ projected = await WebFetcher.projectBytes(byteBody, bodyMime, ctx.projection);
537
+ }
538
+ catch (error) {
539
+ await subscription.notifyChunk(BODY, "", bodyMime);
540
+ throw error;
541
+ }
542
+ if (projected !== null) {
543
+ await _a.#writeProjectionIdentity(subscription, projected.projectionIdentity);
544
+ await subscription.notifyChunk(BODY, projected.content, projected.mimetype);
545
+ await subscription.close({ status: 200 }, `HTTP ${response.status}; ${projected.content.length} readable chars from ${bodyMime}`);
546
+ return { shape: "passthrough", status: 102 };
547
+ }
548
+ await subscription.notifyChunk(BODY, "", bodyMime);
549
+ const detail = `HTTP ${method} ${url} returned ${bodyMime}. The remote response was received, but its binary body cannot be represented in a Plurnk text channel.`;
550
+ const result = _a.#bad(415, "http", "binary-response-unsupported", detail, {
551
+ target: url,
552
+ method,
553
+ mimetype: bodyMime,
554
+ stage: "materialization",
555
+ recovery: "Do not retry the request solely to retrieve this body; inspect #header or use a byte-capable client.",
556
+ retryable: false,
557
+ });
558
+ await subscription.close(result, detail);
559
+ return result;
560
+ }
561
+ // {§http-text-decoding} Fetch text is replacement-mode UTF-8;
562
+ // Content-Type charset remains response evidence, not a second decoder.
329
563
  let bytes = 0;
330
564
  const decoder = new TextDecoder();
331
- for await (const chunk of response.body) {
565
+ for await (const chunk of responseBody) {
332
566
  bytes += chunk.length;
333
- await ctx.subscriptions.notifyChunk(BODY, decoder.decode(chunk, { stream: true }), bodyMime);
567
+ await subscription.notifyChunk(BODY, decoder.decode(chunk, { stream: true }), bodyMime);
334
568
  }
335
569
  const tail = decoder.decode();
336
570
  if (tail.length > 0)
337
- await ctx.subscriptions.notifyChunk(BODY, tail, bodyMime);
338
- await ctx.subscriptions.close("done", `HTTP ${response.status}; ${bytes} bytes`);
571
+ await subscription.notifyChunk(BODY, tail, bodyMime);
572
+ await subscription.close({ status: 200 }, `HTTP ${response.status}; ${bytes} bytes`);
339
573
  return { shape: "passthrough", status: 102 };
340
574
  }
341
575
  catch (err) {
342
576
  const aborted = local.signal.aborted;
343
- const reason = aborted ? "aborted" : err instanceof Error ? err.message : String(err);
344
- await ctx.subscriptions.close(aborted ? "cancelled" : "error", reason);
345
- // 499 for client-cancelled, 502 for upstream/network/render failure.
346
- return _a.#bad(aborted ? 499 : 502, "http", aborted ? "aborted" : "fetch_failed", reason);
577
+ if (aborted) {
578
+ const result = _a.#cancelled(url, method);
579
+ await subscription.close(result, result.problem?.detail);
580
+ return result;
581
+ }
582
+ if (err instanceof WebMaterializationError) {
583
+ const result = _a.#materializationFailure(url, method, err);
584
+ await subscription.close(result, result.problem?.detail);
585
+ return result;
586
+ }
587
+ console.error("HTTP acquisition failed", { method, url, err });
588
+ const cause = ErrorDetail.preview(err, this.#errorDetailLimit);
589
+ const reason = `HTTP ${method} ${url} failed: ${cause}`;
590
+ // The remaining catch owns acquisition failure; cancellation and
591
+ // typed materialization failures settled above.
592
+ const result = _a.#bad(502, "http", "fetch-failed", reason, {
593
+ target: url,
594
+ method,
595
+ stage: "acquisition",
596
+ retryable: true,
597
+ });
598
+ await subscription.close(result, reason);
599
+ return result;
347
600
  }
348
601
  finally {
349
- composed.removeEventListener("abort", onAbort);
602
+ if (!detached)
603
+ subscription.removeEventListener("abort", onAbort);
350
604
  }
351
605
  }
352
- // Seed entry mirroring the manifest's channels — empty content + the seed
353
- // mimetypes (body: octet-stream until the fetch retypes it via notifyChunk,
354
- // header: text/plain). This is the channel-shape knowledge open() lacks; the
355
- // scheme materializes the target so the subscription binds an existing entry
356
- // (http#3). Fresh stream target → no tags.
606
+ // {§http-manifest}/{§http-lifecycle} Seed the declared channel shape before
607
+ // open(); notifyChunk later welds acquired content to its actual mimetype.
357
608
  static #seedEntry() {
358
- const channels = Object.fromEntries(Object.entries(_a.manifest.channels).map(([name, mimetype]) => [name, { content: "", mimetype }]));
609
+ const channels = Object.fromEntries(Object.entries(_a.manifest.channels).map(([name, mimetype]) => [name, {
610
+ content: "",
611
+ mimetype,
612
+ state: "active",
613
+ }]));
359
614
  return { channels, tags: [] };
360
615
  }
361
- // Record the response status line + headers into the HEADER channel (text/plain).
362
- static async #writeHeader(ctx, status, statusText, headers) {
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.
619
+ static #representationComplete(entry) {
620
+ return Object.values(entry.channels).every(({ state }) => state === "static" || state === "closed");
621
+ }
622
+ // {§revalidation} Direct READ and exact FIND share the complete reusable
623
+ // representation predicate; freshness only decides how READ uses a match.
624
+ static async #reusableGetRepresentation(entry, requestHeaders, projection) {
625
+ const body = entry.channels[BODY];
626
+ const header = entry.channels[HEADER];
627
+ if (body === undefined || header === undefined)
628
+ return false;
629
+ return _a.#representationComplete(entry)
630
+ && _a.#requestMethod(header.content) === "GET"
631
+ && requestHeaders.length === 0
632
+ && _a.#cacheVariant(header.content) === "default"
633
+ && !_a.#storedCachePolicy(header.content).noStore
634
+ && await _a.#projectionCurrent(header.content, projection);
635
+ }
636
+ // {§revalidation} Origin headers come first; authoritative package method
637
+ // and acquisition metadata are appended last.
638
+ static async #writeHeader(subscription, method, status, statusText, responseHeaders, requestHeaders) {
363
639
  const lines = [`HTTP ${status} ${statusText}`];
364
- for (const [k, v] of headers)
640
+ for (const [k, v] of responseHeaders)
365
641
  lines.push(`${k}: ${v}`);
642
+ lines.push(`${REQUEST_METHOD}: ${method}`);
366
643
  lines.push(`${FETCHED_AT}: ${new Date().toISOString()}`);
367
- await ctx.subscriptions.notifyChunk(HEADER, lines.join("\n"), "text/plain");
368
- }
369
- // Drain an SSE body through the standard streaming parser and dispatch one
370
- // BODY chunk per event. event/id/retry/comments remain transport metadata:
371
- // this projection publishes data, not the wire, and does not reconnect.
372
- static async #streamEvents(ctx, response) {
373
- if (response.body === null) {
374
- await ctx.subscriptions.close("done", "SSE stream; empty body");
375
- return { shape: "passthrough", status: 102 };
644
+ lines.push(cacheVariantEvidence(classifyCacheVariant(requestHeaders, responseHeaders)));
645
+ await subscription.notifyChunk(HEADER, lines.join("\n"), "text/plain");
646
+ }
647
+ static async #writeProjectionIdentity(subscription, identity) {
648
+ await subscription.notifyChunk(HEADER, `\n${WebFetcher.projectionEvidence(identity)}`, "text/plain");
649
+ }
650
+ // Drain an acquired SSE body and settle its retained subscription. The READ
651
+ // has already returned 102, so terminal failure is durable stream state.
652
+ static async #settleEventStream(subscription, response, options) {
653
+ try {
654
+ const events = await _a.#streamEvents(subscription, response);
655
+ await subscription.close({ status: 200 }, `SSE stream; ${events} events`);
376
656
  }
657
+ catch (error) {
658
+ if (options.signal.aborted) {
659
+ const result = _a.#cancelled(options.url, options.method);
660
+ await subscription.close(result, result.problem?.detail);
661
+ return;
662
+ }
663
+ console.error("HTTP SSE stream failed", { method: options.method, url: options.url, error });
664
+ const cause = ErrorDetail.preview(error, options.errorDetailLimit);
665
+ const reason = `HTTP ${options.method} ${options.url} failed: ${cause}`;
666
+ const result = _a.#bad(502, "http", "fetch-failed", reason, {
667
+ target: options.url,
668
+ method: options.method,
669
+ stage: "transfer",
670
+ retryable: true,
671
+ });
672
+ await subscription.close(result, reason);
673
+ }
674
+ }
675
+ // event/id/retry/comments remain transport metadata: this projection
676
+ // publishes event data, not the wire, and does not reconnect.
677
+ static async #streamEvents(subscription, response) {
678
+ if (response.body === null)
679
+ return 0;
377
680
  const decoder = new TextDecoder();
378
681
  const pending = [];
379
682
  let fatal = null;
@@ -391,7 +694,7 @@ class Http {
391
694
  const publish = async () => {
392
695
  for (const data of pending.splice(0)) {
393
696
  events += 1;
394
- await ctx.subscriptions.notifyChunk(BODY, `${data}\n`, "text/plain");
697
+ await subscription.notifyChunk(BODY, `${data}\n`, "text/plain");
395
698
  }
396
699
  };
397
700
  for await (const chunk of response.body) {
@@ -407,89 +710,295 @@ class Http {
407
710
  if (fatal !== null)
408
711
  throw fatal;
409
712
  await publish();
410
- await ctx.subscriptions.close("done", `SSE stream; ${events} events`);
411
- return { shape: "passthrough", status: 102 };
412
- }
413
- // Reconstruct the absolute URL from the parsed UrlPath. `raw` is the
414
- // grammar's verbatim URL — authoritative; the decomposed fields are a
415
- // convenience. Use raw so query strings / auth / port survive exactly.
416
- static #urlFrom(target) {
417
- return target.raw;
418
- }
419
- // Entry storage has no authority column. The host is part of a web
420
- // resource's identity, so fold it into the pathname exactly as the search
421
- // prefetch sink does. Routing (`https` rides the HTTP handler) remains
422
- // separate from storage (`https` remains https).
423
- static #pathname(target) {
424
- const folded = target.hostname ? `/${target.hostname}${target.pathname}` : target.pathname;
425
- // `%28`/`%29` are the grammar-safe transport spelling of literal path
426
- // parentheses. Entry storage is canonical; the packet renderer encodes
427
- // them again when presenting an address to the model.
428
- return folded.replace(/%28/giu, "(").replace(/%29/giu, ")");
429
- }
430
- // Known-hostile-host rewrite — the ONE bounded, first-party exception
431
- // (SPEC §host-rewrite, schemes-http#4). A GitHub blob page is a CSP-locked JS SPA that renders
432
- // nothing useful, and code wants SOURCE (line-navigable) not markdown pulled
433
- // from a rendered code-viewer; raw.githubusercontent serves the exact bytes
434
- // on the byte path. Measured through the extractor: blob → SPA/JSON noise,
435
- // raw → clean source. The `{ref}/{path}` tail carries through verbatim, so
436
- // slash-bearing branch refs map correctly. Wikipedia was measured too and
437
- // deliberately gets NO rewrite — desktop already extracts to the full clean
438
- // article; every rewrite (action=render, mobile-html) regressed it.
439
- static #rewriteHostileHost(url) {
440
- const gh = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/(.+)$/.exec(url);
441
- if (gh !== null)
442
- return `https://raw.githubusercontent.com/${gh[1]}/${gh[2]}/${gh[3]}`;
443
- return url;
444
- }
445
- // The single freshness-predicate boundary (service#341, #333). Today a
446
- // conditional-GET `304 Not Modified` proves the stored copy is current, so it
447
- // is servable without a re-fetch/re-render. service#333's per-URL TTL lands
448
- // HERE as the SAME predicate (a pre-fetch branch) — one stamp, one rule,
449
- // identical at every entry point. Never add a second freshness check elsewhere.
450
- // Pre-fetch phase (response === null): fresh iff the stamp is inside the
451
- // operator TTL window; PLURNK_SCHEMES_HTTP_TTL_MS=0 disables the window so
452
- // every read revalidates (the pre-TTL behavior). Post-fetch phase: a 304 is
453
- // the origin vouching for the stored copy.
454
- static #storedCopyServable(fetchedAtMs, response) {
455
- if (response === null) {
456
- const ttl = requireNumEnv("PLURNK_SCHEMES_HTTP_TTL_MS");
457
- return ttl > 0 && fetchedAtMs !== undefined && Date.now() - fetchedAtMs < ttl;
458
- }
459
- return response.status === 304;
460
- }
461
- // Parse the materialization stamp out of a stored HEADER channel; undefined
462
- // when absent (pre-TTL entries, execs-materialized pages) — those simply
463
- // never TTL-serve and revalidate instead.
713
+ return events;
714
+ }
715
+ static #address(target) {
716
+ let address;
717
+ try {
718
+ address = NetworkAddress.from(target);
719
+ }
720
+ catch {
721
+ return _a.#bad(400, "http", "bad-target", "HTTP operations require an http(s):// URL with an authority.", {
722
+ stage: "target-validation",
723
+ recovery: "Provide an http(s):// URL with a host.",
724
+ retryable: false,
725
+ });
726
+ }
727
+ if (address.scheme !== "http" && address.scheme !== "https") {
728
+ return _a.#bad(400, "http", "bad-target", "HTTP operations require an http(s):// URL target.", {
729
+ stage: "target-validation",
730
+ recovery: "Provide an http(s):// URL target.",
731
+ retryable: false,
732
+ });
733
+ }
734
+ if (address.hasCredentials) {
735
+ return _a.#bad(400, "http", "userinfo-not-allowed", "HTTP URL userinfo is not allowed.", {
736
+ target: address.url,
737
+ stage: "target-validation",
738
+ recovery: "Remove credentials from the URL and use request metadata where authorization is required.",
739
+ retryable: false,
740
+ });
741
+ }
742
+ return address;
743
+ }
744
+ // {§revalidation} Validation-free reuse requires both the operator's local
745
+ // ceiling and the origin's response policy to remain live.
746
+ static #fresh(header, now = Date.now()) {
747
+ const fetchedAt = _a.#fetchedAt(header);
748
+ const ttl = requireNumEnv("PLURNK_SCHEMES_HTTP_TTL_MS");
749
+ if (fetchedAt === undefined || ttl <= 0)
750
+ return false;
751
+ const residentTime = Math.max(0, now - fetchedAt);
752
+ if (residentTime >= ttl)
753
+ return false;
754
+ const policy = _a.#storedCachePolicy(header);
755
+ if (policy.noStore || policy.noCache)
756
+ return false;
757
+ return policy.freshnessLifetimeMs === undefined
758
+ || _a.#currentAge(header, fetchedAt, now) < policy.freshnessLifetimeMs;
759
+ }
760
+ // Package metadata is appended after origin headers, so the last value wins.
464
761
  static #fetchedAt(priorHeader) {
465
- const m = new RegExp(`^${FETCHED_AT}:[ \\t]*(.+)$`, "im").exec(priorHeader);
466
- if (m === null)
762
+ const value = lastHeaderValue(priorHeader, FETCHED_AT);
763
+ if (value === undefined)
467
764
  return undefined;
468
- const ms = Date.parse(m[1].trim());
765
+ const ms = Date.parse(value);
469
766
  return Number.isNaN(ms) ? undefined : ms;
470
767
  }
471
- // Re-stamp a stored HEADER to now — used when the ORIGIN vouches (304).
472
- static #stamp(header) {
473
- const line = `${FETCHED_AT}: ${new Date().toISOString()}`;
474
- const re = new RegExp(`^${FETCHED_AT}:.*$`, "im");
475
- return re.test(header) ? header.replace(re, line) : `${header}\n${line}`;
768
+ static #headerField(line) {
769
+ const colon = line.indexOf(":");
770
+ if (colon < 0)
771
+ return null;
772
+ const name = line.slice(0, colon).trim().toLowerCase();
773
+ if (name.length === 0)
774
+ return null;
775
+ return { name, value: line.slice(colon + 1).trim(), line };
776
+ }
777
+ static #packageStart(lines) {
778
+ let stampIndex = -1;
779
+ for (const [index, line] of lines.entries()) {
780
+ if (_a.#headerField(line)?.name === FETCHED_AT)
781
+ stampIndex = index;
782
+ }
783
+ if (stampIndex < 0)
784
+ return lines.length;
785
+ return _a.#headerField(lines[stampIndex - 1] ?? "")?.name === REQUEST_METHOD
786
+ ? stampIndex - 1
787
+ : stampIndex;
788
+ }
789
+ static #originFields(header) {
790
+ const lines = header.split(/\r?\n/);
791
+ return lines.slice(1, _a.#packageStart(lines))
792
+ .map((line) => _a.#headerField(line))
793
+ .filter((field) => field !== null);
794
+ }
795
+ static #originValues(fields, name) {
796
+ return fields.filter((field) => field.name === name).map(({ value }) => value);
797
+ }
798
+ static #storedCachePolicy(header) {
799
+ const fields = _a.#originFields(header);
800
+ const directives = _a.#originValues(fields, "cache-control")
801
+ .flatMap((value) => splitHttpList(value))
802
+ .map((member) => cacheDirective(member))
803
+ .filter((directive) => directive !== null);
804
+ const maxAges = directives.filter(({ name }) => name === "max-age");
805
+ let freshnessLifetimeMs;
806
+ if (maxAges.length > 0) {
807
+ freshnessLifetimeMs = maxAges.length === 1
808
+ ? deltaMilliseconds(maxAges[0].argument) ?? 0
809
+ : 0;
810
+ }
811
+ else {
812
+ const expiresValues = _a.#originValues(fields, "expires");
813
+ if (expiresValues.length > 0) {
814
+ const expires = expiresValues.length === 1
815
+ ? httpDate(expiresValues[0])
816
+ : null;
817
+ const fetchedAt = _a.#fetchedAt(header);
818
+ if (expires === null || fetchedAt === undefined) {
819
+ freshnessLifetimeMs = 0;
820
+ }
821
+ else {
822
+ const dateValues = _a.#originValues(fields, "date");
823
+ const parsedDate = dateValues.length === 1
824
+ ? httpDate(dateValues[0])
825
+ : null;
826
+ const generatedAt = parsedDate ?? fetchedAt;
827
+ freshnessLifetimeMs = Math.max(0, expires - generatedAt);
828
+ }
829
+ }
830
+ }
831
+ return {
832
+ noStore: directives.some(({ name }) => name === "no-store"),
833
+ noCache: directives.some(({ name }) => name === "no-cache"),
834
+ ...(freshnessLifetimeMs === undefined ? {} : { freshnessLifetimeMs }),
835
+ };
836
+ }
837
+ static #currentAge(header, fetchedAt, now) {
838
+ const fields = _a.#originFields(header);
839
+ const dateValues = _a.#originValues(fields, "date");
840
+ const date = dateValues.length === 1 ? httpDate(dateValues[0]) : null;
841
+ const apparentAge = date === null ? 0 : Math.max(0, fetchedAt - date);
842
+ const ageMember = _a.#originValues(fields, "age").flatMap(splitHttpList)[0];
843
+ const ageValue = deltaMilliseconds(ageMember) ?? 0;
844
+ return Math.max(apparentAge, ageValue) + Math.max(0, now - fetchedAt);
845
+ }
846
+ static #requestMethod(priorHeader) {
847
+ return lastHeaderValue(priorHeader, REQUEST_METHOD)?.toUpperCase();
848
+ }
849
+ // Package evidence is authoritative only after the package acquisition
850
+ // stamp. An origin field with the same name remains inert.
851
+ static #packageHeaderValue(priorHeader, field) {
852
+ const lines = priorHeader.split(/\r?\n/);
853
+ let stampIndex = -1;
854
+ for (const [index, line] of lines.entries()) {
855
+ const colon = line.indexOf(":");
856
+ if (colon < 0)
857
+ continue;
858
+ const name = line.slice(0, colon).trim().toLowerCase();
859
+ if (name === FETCHED_AT)
860
+ stampIndex = index;
861
+ }
862
+ if (stampIndex < 0)
863
+ return undefined;
864
+ let value;
865
+ for (const line of lines.slice(stampIndex + 1)) {
866
+ const colon = line.indexOf(":");
867
+ if (colon < 0 || line.slice(0, colon).trim().toLowerCase() !== field)
868
+ continue;
869
+ value = line.slice(colon + 1).trim();
870
+ }
871
+ return value === "" ? undefined : value;
872
+ }
873
+ static #projectionIdentity(priorHeader) {
874
+ return _a.#packageHeaderValue(priorHeader, PROJECTION_ID_HEADER);
875
+ }
876
+ static #cacheVariant(priorHeader) {
877
+ const value = _a.#packageHeaderValue(priorHeader, CACHE_VARIANT_HEADER);
878
+ return value === "default" || value === "bypass" ? value : undefined;
879
+ }
880
+ static #sourceMimetype(header) {
881
+ return responseMimetype(lastHeaderValue(header, "content-type") ?? null);
882
+ }
883
+ static async #projectionCurrent(header, projection) {
884
+ const storedIdentity = _a.#projectionIdentity(header);
885
+ if (storedIdentity === undefined)
886
+ return true;
887
+ return await projection.identity(_a.#sourceMimetype(header)) === storedIdentity;
888
+ }
889
+ // RFC 9111 §3.2 permits a processed cache to retain the metadata that its
890
+ // stored body depends upon. Other 304 fields update by name; package-owned
891
+ // evidence is then rebuilt after the origin block. {§revalidation}
892
+ static #refreshAfter304(header, responseHeaders, requestHeaders) {
893
+ const lines = header.split(/\r?\n/);
894
+ const origin = _a.#originFields(header);
895
+ const updates = responseHeaders.filter(([name]) => !BODY_PROCESSING_FIELDS.has(name.toLowerCase()));
896
+ const updatedNames = new Set(updates.map(([name]) => name.toLowerCase()));
897
+ const retained = origin.filter(({ name }) => !updatedNames.has(name));
898
+ const merged = [
899
+ ...retained.map(({ name, value }) => [name, value]),
900
+ ...updates,
901
+ ];
902
+ const projectionIdentity = _a.#projectionIdentity(header);
903
+ return [
904
+ lines[0] ?? "HTTP 200 OK",
905
+ ...retained.map(({ line }) => line),
906
+ ...updates.map(([name, value]) => `${name}: ${value}`),
907
+ `${REQUEST_METHOD}: GET`,
908
+ `${FETCHED_AT}: ${new Date().toISOString()}`,
909
+ cacheVariantEvidence(classifyCacheVariant(requestHeaders, merged)),
910
+ ...(projectionIdentity === undefined
911
+ ? []
912
+ : [WebFetcher.projectionEvidence(projectionIdentity)]),
913
+ ].join("\n");
914
+ }
915
+ static #revalidationCorresponds(priorHeader, responseHeaders) {
916
+ const fields = _a.#originFields(priorHeader);
917
+ const responseTagValue = responseHeaders.get("etag");
918
+ if (responseTagValue !== null) {
919
+ const storedTagValues = _a.#originValues(fields, "etag");
920
+ const responseTag = entityTag(responseTagValue);
921
+ const storedTag = storedTagValues.length === 1
922
+ ? entityTag(storedTagValues[0])
923
+ : null;
924
+ if (responseTag === null || storedTag === null)
925
+ return false;
926
+ return responseTag.opaque === storedTag.opaque
927
+ && (responseTag.weak || !storedTag.weak);
928
+ }
929
+ const responseModifiedValue = responseHeaders.get("last-modified");
930
+ const storedModifiedValues = _a.#originValues(fields, "last-modified");
931
+ const responseModified = responseModifiedValue === null
932
+ ? null
933
+ : httpDate(responseModifiedValue);
934
+ const storedModified = storedModifiedValues.length === 1
935
+ ? httpDate(storedModifiedValues[0])
936
+ : null;
937
+ return responseModified !== null
938
+ && storedModified !== null
939
+ && responseModified === storedModified;
476
940
  }
477
941
  // Conditional-request headers from the prior fetch's stored response headers
478
942
  // (the HEADER channel text #writeHeader wrote): ETag → If-None-Match,
479
943
  // Last-Modified → If-Modified-Since. Empty when neither is present — the
480
944
  // origin then just 200s with a full body, which is correct.
481
945
  static #validators(priorHeader) {
946
+ const fields = _a.#originFields(priorHeader);
482
947
  const out = [];
483
- const etag = /^etag:[ \t]*(.+)$/im.exec(priorHeader);
484
- if (etag !== null)
485
- out.push(["If-None-Match", etag[1].trim()]);
486
- const lastModified = /^last-modified:[ \t]*(.+)$/im.exec(priorHeader);
487
- if (lastModified !== null)
488
- out.push(["If-Modified-Since", lastModified[1].trim()]);
948
+ const etags = _a.#originValues(fields, "etag");
949
+ if (etags.length === 1 && entityTag(etags[0]) !== null) {
950
+ out.push(["If-None-Match", etags[0]]);
951
+ }
952
+ const lastModified = _a.#originValues(fields, "last-modified");
953
+ if (lastModified.length === 1 && httpDate(lastModified[0]) !== null) {
954
+ out.push(["If-Modified-Since", lastModified[0]]);
955
+ }
489
956
  return out;
490
957
  }
491
- static #bad(status, scheme, kind, message) {
492
- return { shape: "passthrough", status, error: Results.error(scheme, kind, message) };
958
+ static #passthrough(result) {
959
+ return Results.assert({ ...result, shape: "passthrough" });
960
+ }
961
+ static #cancelled(url, method) {
962
+ return _a.#bad(499, "http", "cancelled", `HTTP ${method} ${url} was cancelled.`, {
963
+ target: url,
964
+ method,
965
+ stage: "acquisition",
966
+ retryable: false,
967
+ });
968
+ }
969
+ static #materializationFailure(url, method, error) {
970
+ if (error.stage === "projection" && error.cause instanceof ProjectionInputLimitError) {
971
+ return _a.#bad(413, "http", "projection-input-limit", `HTTP ${method} ${url} exceeded the ${error.cause.maximumBytes}-byte readable-projection input limit.`, {
972
+ target: url,
973
+ method,
974
+ mimetype: error.cause.mimetype,
975
+ maximumBytes: error.cause.maximumBytes,
976
+ observedBytes: error.cause.observedBytes,
977
+ stage: "projection",
978
+ retryable: false,
979
+ });
980
+ }
981
+ 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.`, {
986
+ target: url,
987
+ method,
988
+ 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
+ stage: "projection",
997
+ retryable: false,
998
+ });
999
+ }
1000
+ static #bad(status, scheme, kind, message, extensions = {}) {
1001
+ return Results.failure(`scheme:${scheme}`, kind, status, message, { shape: "passthrough" }, extensions);
493
1002
  }
494
1003
  }
495
1004
  _a = Http;