@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/README.md +3 -4
- package/SPEC.md +9 -9
- package/dist/Http.d.ts.map +1 -1
- package/dist/Http.js +93 -464
- package/dist/Http.js.map +1 -1
- package/dist/HttpGet.d.ts +35 -0
- package/dist/HttpGet.d.ts.map +1 -0
- package/dist/HttpGet.js +299 -0
- package/dist/HttpGet.js.map +1 -0
- package/dist/HttpRequester.d.ts +23 -0
- package/dist/HttpRequester.d.ts.map +1 -0
- package/dist/HttpRequester.js +171 -0
- package/dist/HttpRequester.js.map +1 -0
- package/dist/LiveAcquisitions.d.ts +8 -0
- package/dist/LiveAcquisitions.d.ts.map +1 -0
- package/dist/LiveAcquisitions.js +27 -0
- package/dist/LiveAcquisitions.js.map +1 -0
- package/dist/WebFetcher.d.ts +2 -2
- package/dist/WebFetcher.d.ts.map +1 -1
- package/dist/WebFetcher.js +1 -1
- package/dist/WebFetcher.js.map +1 -1
- package/dist/Ws.d.ts.map +1 -1
- package/dist/Ws.js +5 -7
- package/dist/Ws.js.map +1 -1
- package/dist/http-names.d.ts +5 -0
- package/dist/http-names.d.ts.map +1 -0
- package/dist/http-names.js +6 -0
- package/dist/http-names.js.map +1 -0
- package/docs/https.md +8 -7
- package/docs/wss.md +1 -2
- package/package.json +3 -2
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
|
|
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, {
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
464
|
-
//
|
|
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
|
|
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
|
-
|
|
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 —
|
|
476
|
-
//
|
|
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
|
|
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
|
-
|
|
490
|
-
if (status ===
|
|
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
|
|
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(
|
|
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
|
|
679
|
-
&&
|
|
309
|
+
return Http.#representationComplete(entry)
|
|
310
|
+
&& Http.#requestMethod(header.content) === "GET"
|
|
680
311
|
&& requestHeaders.length === 0
|
|
681
|
-
&&
|
|
682
|
-
&& !
|
|
683
|
-
&& await
|
|
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,
|
|
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
|
|
337
|
+
const events = await Http.#streamEvents(subscription, response);
|
|
707
338
|
if (options.signal.aborted) {
|
|
708
|
-
const result =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
459
|
+
const policy = Http.#storedCachePolicy(header);
|
|
829
460
|
if (policy.noStore || policy.noCache)
|
|
830
461
|
return false;
|
|
831
462
|
return policy.freshnessLifetimeMs === undefined
|
|
832
|
-
||
|
|
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 (
|
|
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
|
|
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,
|
|
866
|
-
.map((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 =
|
|
874
|
-
const directives =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
913
|
-
const dateValues =
|
|
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 =
|
|
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
|
|
579
|
+
return Http.#packageHeaderValue(priorHeader, PROJECTION_ID_HEADER);
|
|
949
580
|
}
|
|
950
581
|
static #materializerIdentity(priorHeader) {
|
|
951
|
-
return
|
|
582
|
+
return Http.#packageHeaderValue(priorHeader, MATERIALIZER_ID_HEADER);
|
|
952
583
|
}
|
|
953
584
|
static #cacheVariant(priorHeader) {
|
|
954
|
-
const value =
|
|
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 =
|
|
592
|
+
const sourceMimetype = Http.#sourceMimetype(header);
|
|
962
593
|
const isHtml = MimetypeClassifier.isHtml(sourceMimetype);
|
|
963
|
-
const materializerIdentity =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
659
|
+
const fields = Http.#originFields(priorHeader);
|
|
1029
660
|
const responseTagValue = responseHeaders.get("etag");
|
|
1030
661
|
if (responseTagValue !== null) {
|
|
1031
|
-
const storedTagValues =
|
|
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 =
|
|
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 =
|
|
692
|
+
const fields = Http.#originFields(priorHeader);
|
|
1062
693
|
const out = [];
|
|
1063
|
-
const etags =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|