@plurnk/plurnk-schemes-http 1.14.1 → 1.15.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,21 +1,20 @@
1
1
  // HTTP(S) handler. Operation-to-method semantics live in {§op-surface};
2
2
  // acquisition, materialization, publication, and query flow live in
3
3
  // {§http-lifecycle}. The implementation depends only on SchemeCtx capabilities.
4
- var _a;
5
4
  import { createParser } from "eventsource-parser";
6
- import { MimetypeClassifier, NetworkAddress, ProjectionInputLimitError, Results, } from "@plurnk/plurnk-schemes";
5
+ import { MimetypeClassifier, NetworkAddress, ProjectionInputLimitError, Results } from "@plurnk/plurnk-schemes";
7
6
  import { readFile } from "node:fs/promises";
8
7
  import ErrorDetail from "./ErrorDetail.js";
9
- import WebFetcher, { DEFAULT_WEB_UA, CACHE_VARIANT_HEADER, MATERIALIZER_ID_HEADER, PROJECTION_ID_HEADER, cacheVariantEvidence, classifyCacheVariant, WebMaterializationError, } from "./WebFetcher.js";
8
+ import WebFetcher, { CACHE_VARIANT_HEADER, MATERIALIZER_ID_HEADER, PROJECTION_ID_HEADER, cacheVariantEvidence, classifyCacheVariant } from "./WebFetcher.js";
10
9
  import { responseMimetype } from "./ContentType.js";
11
10
  import { requireNonNegativeIntegerEnv as requireNumEnv } from "./Config.js";
11
+ import { BODY, FETCHED_AT, HEADER, REQUEST_METHOD } from "./http-names.js";
12
+ import HttpGet from "./HttpGet.js";
13
+ import HttpRequester from "./HttpRequester.js";
14
+ import LiveAcquisitions from "./LiveAcquisitions.js";
12
15
  // The channel the response body streams into, and the header metadata channel.
13
- const BODY = "body";
14
- const HEADER = "header";
15
16
  // Package-owned metadata appended after untrusted origin headers. Readers take
16
17
  // the last value so an origin using the same field name cannot override it.
17
- const FETCHED_AT = "x-plurnk-fetched-at";
18
- const REQUEST_METHOD = "x-plurnk-request-method";
19
18
  const DELTA_SECONDS_LIMIT = 2147483648n;
20
19
  const BODY_PROCESSING_FIELDS = new Set([
21
20
  "content-encoding",
@@ -135,8 +134,7 @@ const httpDate = (value, now = Date.now()) => {
135
134
  // The package-shipped model teaching is loaded verbatim and fails hard if absent.
136
135
  // The relative path is identical from src/ during development and dist/ after build.
137
136
  const documentation = await readFile(new URL("../docs/https.md", import.meta.url), "utf-8");
138
- const LLMS_TEXT_ATTEMPT_TTL_MS = 3_600_000;
139
- class Http {
137
+ export default class Http {
140
138
  static manifest = {
141
139
  // "https" is the registered face — plain http folds into it exactly as
142
140
  // ws folds into wss ({§http-manifest}); supported, never advertised as
@@ -164,7 +162,7 @@ class Http {
164
162
  "## EDIT0 (https://api.example.com/v1/pets/42) {Content-Type: application/json}",
165
163
  '{"name":"Mango","status":"available"}',
166
164
  "",
167
- "## SEND0 [200] (https://api.example.com/v1/pets) {Content-Type: application/json}",
165
+ "## SEND0 (https://api.example.com/v1/pets) {Content-Type: application/json}",
168
166
  '{"name":"Mango","status":"available"}',
169
167
  ].join("\n"),
170
168
  documentation,
@@ -175,10 +173,14 @@ class Http {
175
173
  // {§http-llms-txt} — one opportunistic origin-companion attempt per TTL
176
174
  // window; success and failure share the timer so a 404 does not re-probe
177
175
  // on every READ.
178
- #llmsTextAttempts = new Map();
176
+ #get;
177
+ #requester;
178
+ #live = new LiveAcquisitions();
179
179
  constructor() {
180
180
  this.#errorDetailLimit = ErrorDetail.configuredLimit();
181
181
  this.#webFetcher = new WebFetcher();
182
+ this.#get = new HttpGet({ live: this.#live, errorDetailLimit: this.#errorDetailLimit, webFetcher: this.#webFetcher, address: Http.#address, requestHeaders: Http.#requestHeaders, passthrough: Http.#passthrough, requestMethod: Http.#requestMethod, reusableGetRepresentation: Http.#reusableGetRepresentation, materializerIdentity: Http.#materializerIdentity, validators: Http.#validators, materializationFailure: Http.#materializationFailure, sourceMimetype: Http.#sourceMimetype, fresh: Http.#fresh, cancelled: Http.#cancelled, bad: Http.#bad, revalidationCorresponds: Http.#revalidationCorresponds, refreshAfter304: Http.#refreshAfter304, seedEntry: Http.#seedEntry, settleEventStream: Http.#settleEventStream });
183
+ this.#requester = new HttpRequester({ live: this.#live, manifest: Http.manifest, errorDetailLimit: this.#errorDetailLimit, address: Http.#address, requestHeaders: Http.#requestHeaders, bad: Http.#bad, seedEntry: Http.#seedEntry, passthrough: Http.#passthrough, writeHeader: Http.#writeHeader, writeProjectionIdentity: Http.#writeProjectionIdentity, cancelled: Http.#cancelled, materializationFailure: Http.#materializationFailure });
182
184
  }
183
185
  async ready() {
184
186
  WebFetcher.validateConfiguration();
@@ -188,252 +190,19 @@ class Http {
188
190
  // returned 102 for a retained SSE response.
189
191
  async prepareRepresentation(request, ctx) {
190
192
  if (request.target.kind !== "url") {
191
- return _a.#bad(400, "http", "bad-target", "READ requires an http(s):// URL target.", {
193
+ return Http.#bad(400, "http", "bad-target", "READ requires an http(s):// URL target.", {
192
194
  stage: "target-validation",
193
195
  recovery: "Provide an http(s):// URL target.",
194
196
  retryable: false,
195
197
  });
196
198
  }
197
- return this.#prepareGet(request.target, request.metadata, request.pathname, ctx);
198
- }
199
- async #prepareGet(target, metadata, pathname, ctx) {
200
- const address = _a.#address(target);
201
- if (!(address instanceof NetworkAddress))
202
- return address;
203
- const { url } = address;
204
- const requestHeaders = _a.#requestHeaders(metadata);
205
- if (!Array.isArray(requestHeaders))
206
- return requestHeaders;
207
- let cached;
208
- const conditional = [];
209
- const prior = await ctx.entries.read(pathname);
210
- if (Results.isErrorStatus(prior.status) && prior.status !== 404) {
211
- return _a.#passthrough(prior);
212
- }
213
- const priorBody = prior.entry?.channels[BODY];
214
- const priorHeader = prior.entry?.channels[HEADER];
215
- if (prior.entry !== null && _a.#requestMethod(priorHeader?.content ?? "") === undefined) {
216
- return { status: 200 };
217
- }
218
- if (prior.entry !== null && priorBody !== undefined && priorHeader !== undefined) {
219
- try {
220
- if (await _a.#reusableGetRepresentation(prior.entry, requestHeaders, ctx.projection)) {
221
- cached = prior.entry;
222
- if (_a.#materializerIdentity(priorHeader.content) === undefined) {
223
- conditional.push(..._a.#validators(priorHeader.content));
224
- }
225
- }
226
- }
227
- catch (cause) {
228
- return _a.#materializationFailure(url, "GET", new WebMaterializationError(_a.#sourceMimetype(priorHeader.content), cause));
229
- }
230
- }
231
- if (cached !== undefined && _a.#fresh(cached.channels[HEADER].content)) {
232
- return { status: 200 };
233
- }
234
- let fetched;
235
- try {
236
- fetched = await this.#webFetcher.fetch(url, {
237
- signal: ctx.signal,
238
- headers: requestHeaders,
239
- ...(conditional.length > 0
240
- ? { conditionalHeaders: conditional }
241
- : {}),
242
- guarded: false,
243
- acceptHttpErrors: true,
244
- preserveUnavailable: true,
245
- });
246
- }
247
- catch (error) {
248
- if (ctx.signal?.aborted === true && error === ctx.signal.reason) {
249
- return _a.#cancelled(url, "GET");
250
- }
251
- throw error;
252
- }
253
- if (fetched === null) {
254
- return _a.#bad(404, "http", "not-materialized", `The URL ${url} could not be materialized.`, {
255
- target: url,
256
- stage: "acquisition",
257
- retryable: true,
258
- });
259
- }
260
- if (fetched.status === 304) {
261
- if (cached === undefined) {
262
- return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 without a reusable stored representation.`, {
263
- target: url,
264
- method: "GET",
265
- stage: "acquisition",
266
- retryable: true,
267
- });
268
- }
269
- const cachedHeader = cached.channels[HEADER].content;
270
- if (_a.#materializerIdentity(cachedHeader) !== undefined) {
271
- return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 for a stored representation that requires full reacquisition.`, {
272
- target: url,
273
- method: "GET",
274
- stage: "acquisition",
275
- retryable: true,
276
- });
277
- }
278
- const responseHeaders = fetched.responseHeaders ?? [];
279
- if (_a.#revalidationCorresponds(cachedHeader, new Headers(responseHeaders.map(([name, value]) => [name, value])))) {
280
- const channels = {
281
- ...cached.channels,
282
- [HEADER]: {
283
- ...cached.channels[HEADER],
284
- content: _a.#refreshAfter304(cachedHeader, responseHeaders, requestHeaders),
285
- },
286
- };
287
- const written = await ctx.entries.write(pathname, {
288
- channels,
289
- });
290
- if (Results.isErrorStatus(written.status))
291
- return _a.#passthrough(written);
292
- return { status: 200 };
293
- }
294
- // {§revalidation} — a genuinely mismatched 304 (different opaque
295
- // tags): the conditional is the problem, so fall back to one
296
- // unconditional GET and acquire normally instead of surfacing
297
- // an unrecoverable 502. A second 304 has no way out and is the
298
- // honest failure.
299
- try {
300
- fetched = await this.#webFetcher.fetch(url, {
301
- signal: ctx.signal,
302
- headers: requestHeaders,
303
- guarded: false,
304
- acceptHttpErrors: true,
305
- preserveUnavailable: true,
306
- });
307
- }
308
- catch (error) {
309
- if (ctx.signal?.aborted === true && error === ctx.signal.reason) {
310
- return _a.#cancelled(url, "GET");
311
- }
312
- throw error;
313
- }
314
- if (fetched === null) {
315
- return _a.#bad(404, "http", "not-materialized", `The URL ${url} could not be materialized.`, {
316
- target: url,
317
- stage: "acquisition",
318
- retryable: true,
319
- });
320
- }
321
- if (fetched.status === 304) {
322
- return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 without identifying the stored representation nominated for revalidation.`, {
323
- target: url,
324
- method: "GET",
325
- stage: "acquisition",
326
- retryable: true,
327
- });
328
- }
329
- // Non-304: fall through to ordinary acquisition below.
330
- }
331
- if (fetched.mimetype === "text/event-stream"
332
- && fetched.response?.body !== null
333
- && fetched.response?.body !== undefined) {
334
- return this.#openEventStream(address, fetched, ctx);
335
- }
336
- let materialized;
337
- try {
338
- materialized = await WebFetcher.materialize(fetched, ctx.projection, ctx.signal);
339
- }
340
- catch (error) {
341
- if (ctx.signal?.aborted === true)
342
- return _a.#cancelled(url, "GET");
343
- if (error instanceof WebMaterializationError) {
344
- return _a.#materializationFailure(url, "GET", error);
345
- }
346
- throw error;
347
- }
348
- if (materialized === null) {
349
- return _a.#bad(415, "http", "binary-response-unsupported", `HTTP GET ${url} returned ${fetched.mimetype}. The remote response was received, but its binary body cannot be represented in a Plurnk text channel.`, {
350
- target: url,
351
- method: "GET",
352
- mimetype: fetched.mimetype,
353
- stage: "materialization",
354
- recovery: "Inspect #header or use a byte-capable client.",
355
- retryable: false,
356
- });
357
- }
358
- const written = await ctx.entries.write(pathname, {
359
- channels: WebFetcher.materializedChannels(materialized, { url, method: "GET" }),
360
- });
361
- if (Results.isErrorStatus(written.status))
362
- return _a.#passthrough(written);
363
- await this.#piggybackLlmsText(address, ctx);
364
- return { status: 200 };
365
- }
366
- // {§http-llms-txt} — after a successful generic GET materialization,
367
- // acquire <origin>/llms.txt once per TTL window and materialize it as its
368
- // own https entry. Any failure is quiet: the companion never fails the
369
- // READ that piggybacked it, and the companion itself never recurses.
370
- async #piggybackLlmsText(address, ctx) {
371
- const url = new URL(address.url);
372
- if (url.pathname === "/llms.txt")
373
- return;
374
- const origin = url.origin;
375
- const last = this.#llmsTextAttempts.get(origin);
376
- if (last !== undefined && Date.now() - last < LLMS_TEXT_ATTEMPT_TTL_MS)
377
- return;
378
- this.#llmsTextAttempts.set(origin, Date.now());
379
- const llmsUrl = `${origin}/llms.txt`;
380
- try {
381
- const fetched = await this.#webFetcher.fetch(llmsUrl, {
382
- signal: ctx.signal,
383
- guarded: false,
384
- acceptHttpErrors: true,
385
- preserveUnavailable: true,
386
- });
387
- if (fetched === null)
388
- return;
389
- // A missing companion (404) or any non-2xx is quiet — no entry.
390
- if ((fetched.status ?? 200) >= 400)
391
- return;
392
- const materialized = await WebFetcher.materialize(fetched, ctx.projection, ctx.signal);
393
- if (materialized === null)
394
- return;
395
- await ctx.entries.write("/llms.txt", {
396
- channels: WebFetcher.materializedChannels(materialized, { url: llmsUrl, method: "GET" }),
397
- });
398
- }
399
- catch {
400
- // Quiet by contract: 404, guard rejection, network error, binary.
401
- }
402
- }
403
- async #openEventStream(address, fetched, ctx) {
404
- const response = fetched.response;
405
- if (response?.body === null || response?.body === undefined) {
406
- throw new Error("SSE preparation lost its response body.");
407
- }
408
- const local = new AbortController();
409
- const handle = {
410
- cancel: async () => {
411
- local.abort();
412
- await response.body?.cancel().catch(() => { });
413
- },
414
- };
415
- const written = await ctx.entries.write(address.pathname, _a.#seedEntry());
416
- if (Results.isErrorStatus(written.status))
417
- return _a.#passthrough(written);
418
- const subscription = await ctx.subscriptions.open(address.pathname, handle);
419
- if (fetched.header !== undefined) {
420
- await subscription.notifyChunk(HEADER, fetched.header, "text/plain");
421
- }
422
- void _a.#settleEventStream(subscription, response, {
423
- url: address.url,
424
- method: "GET",
425
- signal: local.signal,
426
- errorDetailLimit: this.#errorDetailLimit,
427
- }).catch((error) => {
428
- console.error("HTTP SSE terminal cleanup failed", { url: address.url, error });
429
- });
430
- return { shape: "passthrough", status: 102 };
199
+ return this.#get.prepareGet(request.target, request.metadata, request.pathname, ctx);
431
200
  }
432
201
  // EDIT -> PUT the body (full-resource replace). `<L>` has no meaning against a
433
202
  // remote resource - reject rather than silently ignore the model's intent.
434
203
  async editBatch(statements, ctx) {
435
204
  if (statements.length !== 1) {
436
- return _a.#bad(409, "http", "non-atomic-edit-batch", `${statements.length} EDIT statements cannot be committed atomically to one remote resource.`, {
205
+ return Http.#bad(409, "http", "non-atomic-edit-batch", `${statements.length} EDIT statements cannot be committed atomically to one remote resource.`, {
437
206
  statements: statements.length,
438
207
  stage: "mutation",
439
208
  recovery: "Submit one whole-resource EDIT.",
@@ -442,215 +211,77 @@ class Http {
442
211
  }
443
212
  const statement = statements[0];
444
213
  if (statement.target === null || statement.target.kind !== "url") {
445
- return _a.#bad(400, "http", "bad-target", "EDIT requires an http(s):// URL target.", {
214
+ return Http.#bad(400, "http", "bad-target", "EDIT requires an http(s):// URL target.", {
446
215
  stage: "target-validation",
447
216
  recovery: "Provide an http(s):// URL target.",
448
217
  retryable: false,
449
218
  });
450
219
  }
451
220
  if (statement.lineMarker !== null) {
452
- return _a.#bad(400, "http", "line-edit-unsupported", "HTTP EDIT replaces the whole remote resource and does not accept a line range.", {
221
+ return Http.#bad(400, "http", "line-edit-unsupported", "HTTP EDIT replaces the whole remote resource and does not accept a line range.", {
453
222
  stage: "mutation",
454
223
  recovery: "Remove the line range and submit the complete replacement body.",
455
224
  retryable: false,
456
225
  });
457
226
  }
458
- return this.#request(statement.target, statement.metadata, ctx, "PUT", statement.body ?? "");
227
+ return this.#requester.request(statement.target, statement.metadata, ctx, "PUT", statement.body ?? "");
459
228
  }
460
229
  async edit(statement, ctx) {
461
230
  return this.editBatch([statement], ctx);
462
231
  }
463
- // KILL -> DELETE the resource. Distinct from SEND signal 410 (which drops the local
464
- // cached entry): KILL is an HTTP DELETE request to the remote.
232
+ // {§http-kill} — KILL follows the entry rule: a live acquisition of the address is
233
+ // cancelled, otherwise the stored response is forgotten. The remote DELETE is its own
234
+ // spelling, `## KILL0 (https://…) {remote}`; any other metadata blocks are its headers.
465
235
  async kill(statement, ctx) {
466
236
  if (statement.target === null || statement.target.kind !== "url") {
467
- return _a.#bad(400, "http", "bad-target", "KILL requires an http(s):// URL target.", {
237
+ return Http.#bad(400, "http", "bad-target", "KILL requires an http(s):// URL target.", {
468
238
  stage: "target-validation",
469
239
  recovery: "Provide an http(s):// URL target.",
470
240
  retryable: false,
471
241
  });
472
242
  }
473
- return this.#request(statement.target, statement.metadata, ctx, "DELETE", statement.body ?? undefined);
243
+ const blocks = statement.metadata ?? [];
244
+ const remote = blocks.some((block) => block.trim() === "remote");
245
+ if (remote) {
246
+ const headers = blocks.filter((block) => block.trim() !== "remote");
247
+ return this.#requester.request(statement.target, headers.length === 0 ? null : headers, ctx, "DELETE", undefined);
248
+ }
249
+ const address = Http.#address(statement.target);
250
+ if (!(address instanceof NetworkAddress))
251
+ return address;
252
+ if (this.#live.cancel(LiveAcquisitions.key(ctx.workerId, address.url))) {
253
+ // The owner settles itself as cancelled through its aborted signal.
254
+ return { shape: "passthrough", status: 200 };
255
+ }
256
+ return Http.#passthrough(await ctx.entries.delete(address.pathname));
474
257
  }
475
- // SEND dispatch — status-code-as-verb (SPEC {§op-surface}).
476
- // 200 -> request with body (POST), stream response
477
- // 410 -> delete the cached entry
478
- // 499 -> cancel in-flight (handled by the subscription's force-cancel;
479
- // the engine routes 499 to the registered SubscriptionHandle, so a
480
- // scheme-level no-op here is correct — teardown already happened)
258
+ // SEND dispatch — a recipient SEND with a body is the POST; a disposition label never
259
+ // reaches a scheme, and any other status is refused 501 ({§send-label}).
481
260
  async send(statement, ctx) {
482
261
  if (statement.target === null || statement.target.kind !== "url") {
483
- return _a.#bad(400, "http", "bad-target", "SEND requires an http(s):// URL target.", {
262
+ return Http.#bad(400, "http", "bad-target", "SEND requires an http(s):// URL target.", {
484
263
  stage: "target-validation",
485
264
  recovery: "Provide an http(s):// URL target.",
486
265
  retryable: false,
487
266
  });
488
267
  }
489
- const status = statement.signal;
490
- if (status === 200) {
268
+ // A recipient SEND with a body posts it ({§send-label}: dispositions never reach a scheme).
269
+ if (statement.status === null) {
491
270
  const body = statement.body?.raw ?? "";
492
- return this.#request(statement.target, statement.metadata, ctx, "POST", body);
493
- }
494
- if (statement.metadata !== null) {
495
- return _a.#bad(400, "http", "metadata-not-applicable", `HTTP SEND status ${status} does not issue a request, so it does not accept {metadata}.`, { requestedStatus: status, retryable: false });
496
- }
497
- if (status === 410) {
498
- const address = _a.#address(statement.target);
499
- if (!(address instanceof NetworkAddress))
500
- return address;
501
- return _a.#passthrough(await ctx.entries.delete(address.pathname));
502
- }
503
- if (status === 499) {
504
- // Cancellation is routed by the engine to the subscription's
505
- // SubscriptionHandle.cancel (registered by #request). Nothing
506
- // for the scheme to do at the op level.
507
- return { shape: "passthrough", status: 200 };
271
+ return this.#requester.request(statement.target, statement.metadata, ctx, "POST", body);
508
272
  }
273
+ const status = statement.status;
509
274
  // Entry-bearing schemes return 501 for status codes they don't interpret.
510
- return _a.#bad(501, "http", "send-status-unsupported", `The HTTP scheme does not interpret SEND status ${status}.`, {
275
+ return Http.#bad(501, "http", "send-status-unsupported", `The HTTP scheme does not interpret SEND status ${status}.`, {
511
276
  requestedStatus: status,
512
277
  stage: "dispatch",
513
278
  retryable: false,
514
279
  });
515
280
  }
516
- // Mutation responses use the same entry/channel and subscription primitives
517
- // as every live producer; GET acquisition belongs solely to
518
- // prepareRepresentation.
519
- async #request(target, metadata, ctx, method, body) {
520
- const address = _a.#address(target);
521
- if (!(address instanceof NetworkAddress))
522
- return address;
523
- const { url } = address;
524
- const { pathname } = address;
525
- const headers = _a.#requestHeaders(metadata);
526
- if (!Array.isArray(headers))
527
- return headers;
528
- const publishedChannel = target.fragment ?? _a.manifest.defaultChannel;
529
- if (!(publishedChannel in _a.manifest.channels)) {
530
- const availableChannels = Object.keys(_a.manifest.channels);
531
- return _a.#bad(400, "http", "channel-not-found", `Channel #${publishedChannel} does not exist on HTTP responses.`, {
532
- requestedChannel: publishedChannel,
533
- availableChannels,
534
- recovery: `Use one of the available channels: ${availableChannels.map((channel) => `#${channel}`).join(", ")}.`,
535
- retryable: false,
536
- });
537
- }
538
- // Local AbortController for force-cancel from outside (SEND signal 499).
539
- const local = new AbortController();
540
- const handle = { cancel: () => local.abort() };
541
- // {§http-lifecycle} open() binds an existing entry, so the handler seeds
542
- // its manifest-owned channel shape before subscribing.
543
- const written = await ctx.entries.write(pathname, _a.#seedEntry());
544
- if (Results.isErrorStatus(written.status))
545
- return _a.#passthrough(written);
546
- // open() returns the worker+teardown-composed signal — fires on loop.cancel
547
- // OR our local teardown. Wire it so either path aborts acquisition.
548
- const subscription = await ctx.subscriptions.open(pathname, handle);
549
- const onAbort = () => local.abort();
550
- subscription.addEventListener("abort", onAbort, { once: true });
551
- try {
552
- const response = await fetch(url, {
553
- method,
554
- body,
555
- headers: headers.some(([k]) => k.toLowerCase() === "user-agent")
556
- ? headers
557
- : [["User-Agent", DEFAULT_WEB_UA], ...headers],
558
- signal: local.signal,
559
- redirect: "follow",
560
- });
561
- const responseMime = responseMimetype(response.headers.get("content-type"));
562
- // {§http-lifecycle}/{§mimetype-classifier} String channels retain
563
- // textual response data. Binary input is transient: an installed
564
- // reader may derive Unicode, otherwise the durable body is a typed
565
- // empty marker rather than a fabricated byte channel.
566
- await _a.#writeHeader(subscription, method, response.status, response.statusText, [...response.headers], headers);
567
- const bodyMime = responseMime;
568
- if (response.body === null) {
569
- await subscription.close({ status: 200 }, `HTTP ${response.status}; empty body`);
570
- return { shape: "passthrough", status: 102 };
571
- }
572
- const responseBody = response.body;
573
- const byteBody = {
574
- chunks: responseBody,
575
- cancel: () => responseBody.cancel(),
576
- };
577
- const binary = await WebFetcher.classifyBinary(byteBody, bodyMime, ctx.projection);
578
- if (binary) {
579
- let projected;
580
- try {
581
- projected = await WebFetcher.projectBytes(byteBody, bodyMime, ctx.projection);
582
- }
583
- catch (error) {
584
- await subscription.notifyChunk(BODY, "", bodyMime);
585
- throw error;
586
- }
587
- if (projected !== null) {
588
- await _a.#writeProjectionIdentity(subscription, projected.projectionIdentity);
589
- await subscription.notifyChunk(BODY, projected.content, projected.mimetype);
590
- await subscription.close({ status: 200 }, `HTTP ${response.status}; ${projected.content.length} readable chars from ${bodyMime}`);
591
- return { shape: "passthrough", status: 102 };
592
- }
593
- await subscription.notifyChunk(BODY, "", bodyMime);
594
- const detail = `HTTP ${method} ${url} returned ${bodyMime}. The remote response was received, but its binary body cannot be represented in a Plurnk text channel.`;
595
- const result = _a.#bad(415, "http", "binary-response-unsupported", detail, {
596
- target: url,
597
- method,
598
- mimetype: bodyMime,
599
- stage: "materialization",
600
- recovery: "Do not retry the request solely to retrieve this body; inspect #header or use a byte-capable client.",
601
- retryable: false,
602
- });
603
- await subscription.close(result, detail);
604
- return result;
605
- }
606
- // {§http-text-decoding} Fetch text is replacement-mode UTF-8;
607
- // Content-Type charset remains response evidence, not a second decoder.
608
- let bytes = 0;
609
- const decoder = new TextDecoder();
610
- for await (const chunk of responseBody) {
611
- bytes += chunk.length;
612
- await subscription.notifyChunk(BODY, decoder.decode(chunk, { stream: true }), bodyMime);
613
- }
614
- const tail = decoder.decode();
615
- if (tail.length > 0)
616
- await subscription.notifyChunk(BODY, tail, bodyMime);
617
- await subscription.close({ status: 200 }, `HTTP ${response.status}; ${bytes} bytes`);
618
- return { shape: "passthrough", status: 102 };
619
- }
620
- catch (err) {
621
- const aborted = local.signal.aborted;
622
- if (aborted) {
623
- const result = _a.#cancelled(url, method);
624
- await subscription.close(result, result.problem?.detail);
625
- return result;
626
- }
627
- if (err instanceof WebMaterializationError) {
628
- const result = _a.#materializationFailure(url, method, err);
629
- await subscription.close(result, result.problem?.detail);
630
- return result;
631
- }
632
- console.error("HTTP acquisition failed", { method, url, err });
633
- const cause = ErrorDetail.preview(err, this.#errorDetailLimit);
634
- const reason = `HTTP ${method} ${url} failed: ${cause}`;
635
- // The remaining catch owns acquisition failure; cancellation and
636
- // typed materialization failures settled above.
637
- const result = _a.#bad(502, "http", "fetch-failed", reason, {
638
- target: url,
639
- method,
640
- stage: "acquisition",
641
- retryable: method !== "POST",
642
- });
643
- await subscription.close(result, reason);
644
- return result;
645
- }
646
- finally {
647
- subscription.removeEventListener("abort", onAbort);
648
- }
649
- }
650
281
  // {§http-manifest}/{§http-lifecycle} Seed the declared channel shape before
651
282
  // open(); notifyChunk later welds acquired content to its actual mimetype.
652
283
  static #seedEntry() {
653
- const channels = Object.fromEntries(Object.entries(_a.manifest.channels).map(([name, mimetype]) => [name, {
284
+ const channels = Object.fromEntries(Object.entries(Http.manifest.channels).map(([name, mimetype]) => [name, {
654
285
  content: "",
655
286
  mimetype,
656
287
  state: "active",
@@ -675,17 +306,17 @@ class Http {
675
306
  const header = entry.channels[HEADER];
676
307
  if (body === undefined || header === undefined)
677
308
  return false;
678
- return _a.#representationComplete(entry)
679
- && _a.#requestMethod(header.content) === "GET"
309
+ return Http.#representationComplete(entry)
310
+ && Http.#requestMethod(header.content) === "GET"
680
311
  && requestHeaders.length === 0
681
- && _a.#cacheVariant(header.content) === "default"
682
- && !_a.#storedCachePolicy(header.content).noStore
683
- && await _a.#projectionCurrent(header.content, projection);
312
+ && Http.#cacheVariant(header.content) === "default"
313
+ && !Http.#storedCachePolicy(header.content).noStore
314
+ && await Http.#projectionCurrent(header.content, projection);
684
315
  }
685
316
  // {§revalidation} Origin headers come first; authoritative package method
686
317
  // and acquisition metadata are appended last.
687
318
  static async #writeHeader(subscription, method, status, statusText, responseHeaders, requestHeaders) {
688
- await subscription.notifyChunk(HEADER, _a.#responseHeader(method, status, statusText, responseHeaders, requestHeaders), "text/plain");
319
+ await subscription.notifyChunk(HEADER, Http.#responseHeader(method, status, statusText, responseHeaders, requestHeaders), "text/plain");
689
320
  }
690
321
  static #responseHeader(method, status, statusText, responseHeaders, requestHeaders) {
691
322
  const lines = [`HTTP ${status} ${statusText}`];
@@ -703,9 +334,9 @@ class Http {
703
334
  // has already returned 102, so terminal failure is durable stream state.
704
335
  static async #settleEventStream(subscription, response, options) {
705
336
  try {
706
- const events = await _a.#streamEvents(subscription, response);
337
+ const events = await Http.#streamEvents(subscription, response);
707
338
  if (options.signal.aborted) {
708
- const result = _a.#cancelled(options.url, options.method);
339
+ const result = Http.#cancelled(options.url, options.method);
709
340
  await subscription.close(result, result.problem?.detail);
710
341
  return;
711
342
  }
@@ -713,14 +344,14 @@ class Http {
713
344
  }
714
345
  catch (error) {
715
346
  if (options.signal.aborted) {
716
- const result = _a.#cancelled(options.url, options.method);
347
+ const result = Http.#cancelled(options.url, options.method);
717
348
  await subscription.close(result, result.problem?.detail);
718
349
  return;
719
350
  }
720
351
  console.error("HTTP SSE stream failed", { method: options.method, url: options.url, error });
721
352
  const cause = ErrorDetail.preview(error, options.errorDetailLimit);
722
353
  const reason = `HTTP ${options.method} ${options.url} failed: ${cause}`;
723
- const result = _a.#bad(502, "http", "fetch-failed", reason, {
354
+ const result = Http.#bad(502, "http", "fetch-failed", reason, {
724
355
  target: options.url,
725
356
  method: options.method,
726
357
  stage: "transfer",
@@ -775,21 +406,21 @@ class Http {
775
406
  address = NetworkAddress.from(target);
776
407
  }
777
408
  catch {
778
- return _a.#bad(400, "http", "bad-target", "HTTP operations require an http(s):// URL with an authority.", {
409
+ return Http.#bad(400, "http", "bad-target", "HTTP operations require an http(s):// URL with an authority.", {
779
410
  stage: "target-validation",
780
411
  recovery: "Provide an http(s):// URL with a host.",
781
412
  retryable: false,
782
413
  });
783
414
  }
784
415
  if (address.scheme !== "http" && address.scheme !== "https") {
785
- return _a.#bad(400, "http", "bad-target", "HTTP operations require an http(s):// URL target.", {
416
+ return Http.#bad(400, "http", "bad-target", "HTTP operations require an http(s):// URL target.", {
786
417
  stage: "target-validation",
787
418
  recovery: "Provide an http(s):// URL target.",
788
419
  retryable: false,
789
420
  });
790
421
  }
791
422
  if (address.hasCredentials) {
792
- return _a.#bad(400, "http", "userinfo-not-allowed", "HTTP URL userinfo is not allowed.", {
423
+ return Http.#bad(400, "http", "userinfo-not-allowed", "HTTP URL userinfo is not allowed.", {
793
424
  target: address.url,
794
425
  stage: "target-validation",
795
426
  recovery: "Remove credentials from the URL and use request metadata where authorization is required.",
@@ -805,11 +436,11 @@ class Http {
805
436
  for (const [index, block] of metadata.entries()) {
806
437
  const colon = block.indexOf(":");
807
438
  if (colon < 0) {
808
- return _a.#bad(400, "http", "metadata-header-shape", `HTTP metadata block ${index + 1} requires a header name and ':' separator.`, { block: index + 1, retryable: false });
439
+ return Http.#bad(400, "http", "metadata-header-shape", `HTTP metadata block ${index + 1} requires a header name and ':' separator.`, { block: index + 1, retryable: false });
809
440
  }
810
441
  const name = block.slice(0, colon).trim();
811
442
  if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u.test(name)) {
812
- return _a.#bad(400, "http", "metadata-header-name", `HTTP metadata block ${index + 1} has an invalid header name.`, { block: index + 1, retryable: false });
443
+ return Http.#bad(400, "http", "metadata-header-name", `HTTP metadata block ${index + 1} has an invalid header name.`, { block: index + 1, retryable: false });
813
444
  }
814
445
  headers.push([name, block.slice(colon + 1).trim()]);
815
446
  }
@@ -818,18 +449,18 @@ class Http {
818
449
  // {§revalidation} Validation-free reuse requires both the operator's local
819
450
  // ceiling and the origin's response policy to remain live.
820
451
  static #fresh(header, now = Date.now()) {
821
- const fetchedAt = _a.#fetchedAt(header);
452
+ const fetchedAt = Http.#fetchedAt(header);
822
453
  const ttl = requireNumEnv("PLURNK_SCHEMES_HTTP_TTL_MS");
823
454
  if (fetchedAt === undefined || ttl <= 0)
824
455
  return false;
825
456
  const residentTime = Math.max(0, now - fetchedAt);
826
457
  if (residentTime >= ttl)
827
458
  return false;
828
- const policy = _a.#storedCachePolicy(header);
459
+ const policy = Http.#storedCachePolicy(header);
829
460
  if (policy.noStore || policy.noCache)
830
461
  return false;
831
462
  return policy.freshnessLifetimeMs === undefined
832
- || _a.#currentAge(header, fetchedAt, now) < policy.freshnessLifetimeMs;
463
+ || Http.#currentAge(header, fetchedAt, now) < policy.freshnessLifetimeMs;
833
464
  }
834
465
  // Package metadata is appended after origin headers, so the last value wins.
835
466
  static #fetchedAt(priorHeader) {
@@ -851,27 +482,27 @@ class Http {
851
482
  static #packageStart(lines) {
852
483
  let stampIndex = -1;
853
484
  for (const [index, line] of lines.entries()) {
854
- if (_a.#headerField(line)?.name === FETCHED_AT)
485
+ if (Http.#headerField(line)?.name === FETCHED_AT)
855
486
  stampIndex = index;
856
487
  }
857
488
  if (stampIndex < 0)
858
489
  return lines.length;
859
- return _a.#headerField(lines[stampIndex - 1] ?? "")?.name === REQUEST_METHOD
490
+ return Http.#headerField(lines[stampIndex - 1] ?? "")?.name === REQUEST_METHOD
860
491
  ? stampIndex - 1
861
492
  : stampIndex;
862
493
  }
863
494
  static #originFields(header) {
864
495
  const lines = header.split(/\r?\n/);
865
- return lines.slice(1, _a.#packageStart(lines))
866
- .map((line) => _a.#headerField(line))
496
+ return lines.slice(1, Http.#packageStart(lines))
497
+ .map((line) => Http.#headerField(line))
867
498
  .filter((field) => field !== null);
868
499
  }
869
500
  static #originValues(fields, name) {
870
501
  return fields.filter((field) => field.name === name).map(({ value }) => value);
871
502
  }
872
503
  static #storedCachePolicy(header) {
873
- const fields = _a.#originFields(header);
874
- const directives = _a.#originValues(fields, "cache-control")
504
+ const fields = Http.#originFields(header);
505
+ const directives = Http.#originValues(fields, "cache-control")
875
506
  .flatMap((value) => splitHttpList(value))
876
507
  .map((member) => cacheDirective(member))
877
508
  .filter((directive) => directive !== null);
@@ -883,17 +514,17 @@ class Http {
883
514
  : 0;
884
515
  }
885
516
  else {
886
- const expiresValues = _a.#originValues(fields, "expires");
517
+ const expiresValues = Http.#originValues(fields, "expires");
887
518
  if (expiresValues.length > 0) {
888
519
  const expires = expiresValues.length === 1
889
520
  ? httpDate(expiresValues[0])
890
521
  : null;
891
- const fetchedAt = _a.#fetchedAt(header);
522
+ const fetchedAt = Http.#fetchedAt(header);
892
523
  if (expires === null || fetchedAt === undefined) {
893
524
  freshnessLifetimeMs = 0;
894
525
  }
895
526
  else {
896
- const dateValues = _a.#originValues(fields, "date");
527
+ const dateValues = Http.#originValues(fields, "date");
897
528
  const parsedDate = dateValues.length === 1
898
529
  ? httpDate(dateValues[0])
899
530
  : null;
@@ -909,11 +540,11 @@ class Http {
909
540
  };
910
541
  }
911
542
  static #currentAge(header, fetchedAt, now) {
912
- const fields = _a.#originFields(header);
913
- const dateValues = _a.#originValues(fields, "date");
543
+ const fields = Http.#originFields(header);
544
+ const dateValues = Http.#originValues(fields, "date");
914
545
  const date = dateValues.length === 1 ? httpDate(dateValues[0]) : null;
915
546
  const apparentAge = date === null ? 0 : Math.max(0, fetchedAt - date);
916
- const ageMember = _a.#originValues(fields, "age").flatMap(splitHttpList)[0];
547
+ const ageMember = Http.#originValues(fields, "age").flatMap(splitHttpList)[0];
917
548
  const ageValue = deltaMilliseconds(ageMember) ?? 0;
918
549
  return Math.max(apparentAge, ageValue) + Math.max(0, now - fetchedAt);
919
550
  }
@@ -945,22 +576,22 @@ class Http {
945
576
  return value === "" ? undefined : value;
946
577
  }
947
578
  static #projectionIdentity(priorHeader) {
948
- return _a.#packageHeaderValue(priorHeader, PROJECTION_ID_HEADER);
579
+ return Http.#packageHeaderValue(priorHeader, PROJECTION_ID_HEADER);
949
580
  }
950
581
  static #materializerIdentity(priorHeader) {
951
- return _a.#packageHeaderValue(priorHeader, MATERIALIZER_ID_HEADER);
582
+ return Http.#packageHeaderValue(priorHeader, MATERIALIZER_ID_HEADER);
952
583
  }
953
584
  static #cacheVariant(priorHeader) {
954
- const value = _a.#packageHeaderValue(priorHeader, CACHE_VARIANT_HEADER);
585
+ const value = Http.#packageHeaderValue(priorHeader, CACHE_VARIANT_HEADER);
955
586
  return value === "default" || value === "bypass" ? value : undefined;
956
587
  }
957
588
  static #sourceMimetype(header) {
958
589
  return responseMimetype(lastHeaderValue(header, "content-type") ?? null);
959
590
  }
960
591
  static async #projectionCurrent(header, projection) {
961
- const sourceMimetype = _a.#sourceMimetype(header);
592
+ const sourceMimetype = Http.#sourceMimetype(header);
962
593
  const isHtml = MimetypeClassifier.isHtml(sourceMimetype);
963
- const materializerIdentity = _a.#materializerIdentity(header);
594
+ const materializerIdentity = Http.#materializerIdentity(header);
964
595
  if (materializerIdentity !== undefined) {
965
596
  // {§http-materializer-plugins} — a stored body produced by the current
966
597
  // configured materializer (or the built-in producers) is current.
@@ -968,7 +599,7 @@ class Http {
968
599
  }
969
600
  if (isHtml && materializerIdentity === undefined)
970
601
  return false;
971
- const storedIdentity = _a.#projectionIdentity(header);
602
+ const storedIdentity = Http.#projectionIdentity(header);
972
603
  if (storedIdentity === undefined)
973
604
  return !isHtml;
974
605
  return await projection.identity(sourceMimetype) === storedIdentity;
@@ -978,7 +609,7 @@ class Http {
978
609
  // evidence is then rebuilt after the origin block. {§revalidation}
979
610
  static #refreshAfter304(header, responseHeaders, requestHeaders) {
980
611
  const lines = header.split(/\r?\n/);
981
- const origin = _a.#originFields(header);
612
+ const origin = Http.#originFields(header);
982
613
  const updates = responseHeaders.filter(([name]) => !BODY_PROCESSING_FIELDS.has(name.toLowerCase()));
983
614
  const updatedNames = new Set(updates.map(([name]) => name.toLowerCase()));
984
615
  const retained = origin.filter(({ name }) => !updatedNames.has(name));
@@ -992,7 +623,7 @@ class Http {
992
623
  // x-plurnk-* fields (cache-variant, materializer/projection ids, method,
993
624
  // fetched-at stamp) are rebuilt or omitted, never duplicated stale.
994
625
  const rebuilt = [MATERIALIZER_ID_HEADER, PROJECTION_ID_HEADER].flatMap((field) => {
995
- const value = _a.#packageHeaderValue(header, field);
626
+ const value = Http.#packageHeaderValue(header, field);
996
627
  return value === undefined ? [] : [`${field}: ${value}`];
997
628
  });
998
629
  const frameworkOwned = new Set([
@@ -1025,10 +656,10 @@ class Http {
1025
656
  ].join("\n");
1026
657
  }
1027
658
  static #revalidationCorresponds(priorHeader, responseHeaders) {
1028
- const fields = _a.#originFields(priorHeader);
659
+ const fields = Http.#originFields(priorHeader);
1029
660
  const responseTagValue = responseHeaders.get("etag");
1030
661
  if (responseTagValue !== null) {
1031
- const storedTagValues = _a.#originValues(fields, "etag");
662
+ const storedTagValues = Http.#originValues(fields, "etag");
1032
663
  const responseTag = entityTag(responseTagValue);
1033
664
  const storedTag = storedTagValues.length === 1
1034
665
  ? entityTag(storedTagValues[0])
@@ -1042,7 +673,7 @@ class Http {
1042
673
  return responseTag.opaque === storedTag.opaque;
1043
674
  }
1044
675
  const responseModifiedValue = responseHeaders.get("last-modified");
1045
- const storedModifiedValues = _a.#originValues(fields, "last-modified");
676
+ const storedModifiedValues = Http.#originValues(fields, "last-modified");
1046
677
  const responseModified = responseModifiedValue === null
1047
678
  ? null
1048
679
  : httpDate(responseModifiedValue);
@@ -1058,13 +689,13 @@ class Http {
1058
689
  // Last-Modified → If-Modified-Since. Empty when neither is present — the
1059
690
  // origin then just 200s with a full body, which is correct.
1060
691
  static #validators(priorHeader) {
1061
- const fields = _a.#originFields(priorHeader);
692
+ const fields = Http.#originFields(priorHeader);
1062
693
  const out = [];
1063
- const etags = _a.#originValues(fields, "etag");
694
+ const etags = Http.#originValues(fields, "etag");
1064
695
  if (etags.length === 1 && entityTag(etags[0]) !== null) {
1065
696
  out.push(["If-None-Match", etags[0]]);
1066
697
  }
1067
- const lastModified = _a.#originValues(fields, "last-modified");
698
+ const lastModified = Http.#originValues(fields, "last-modified");
1068
699
  if (lastModified.length === 1 && httpDate(lastModified[0]) !== null) {
1069
700
  out.push(["If-Modified-Since", lastModified[0]]);
1070
701
  }
@@ -1074,7 +705,7 @@ class Http {
1074
705
  return Results.assertChannelProducerResult({ ...result, shape: "passthrough" });
1075
706
  }
1076
707
  static #cancelled(url, method) {
1077
- return _a.#bad(499, "http", "cancelled", `HTTP ${method} ${url} was cancelled.`, {
708
+ return Http.#bad(499, "http", "cancelled", `HTTP ${method} ${url} was cancelled.`, {
1078
709
  target: url,
1079
710
  method,
1080
711
  stage: "acquisition",
@@ -1083,7 +714,7 @@ class Http {
1083
714
  }
1084
715
  static #materializationFailure(url, method, error) {
1085
716
  if (error.stage === "projection" && error.cause instanceof ProjectionInputLimitError) {
1086
- return _a.#bad(413, "http", "projection-input-limit", `HTTP ${method} ${url} exceeded the ${error.cause.maximumBytes}-byte readable-projection input limit.`, {
717
+ return Http.#bad(413, "http", "projection-input-limit", `HTTP ${method} ${url} exceeded the ${error.cause.maximumBytes}-byte readable-projection input limit.`, {
1087
718
  target: url,
1088
719
  method,
1089
720
  mimetype: error.cause.mimetype,
@@ -1094,7 +725,7 @@ class Http {
1094
725
  });
1095
726
  }
1096
727
  console.error("HTTP materialization failed", { method, url, error });
1097
- return _a.#bad(500, "http", "projection-failed", `HTTP ${method} ${url} acquired content, but its readable projection failed.`, {
728
+ return Http.#bad(500, "http", "projection-failed", `HTTP ${method} ${url} acquired content, but its readable projection failed.`, {
1098
729
  target: url,
1099
730
  method,
1100
731
  mimetype: error.mimetype,
@@ -1106,6 +737,4 @@ class Http {
1106
737
  return Results.failure(`scheme:${scheme}`, kind, status, message, { shape: "passthrough" }, extensions);
1107
738
  }
1108
739
  }
1109
- _a = Http;
1110
- export default Http;
1111
740
  //# sourceMappingURL=Http.js.map