@plurnk/plurnk-schemes-http 2.0.5 → 2.0.6
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 +4 -4
- package/SPEC.md +122 -606
- package/dist/Guard.d.ts +4 -0
- package/dist/Guard.d.ts.map +1 -1
- package/dist/Guard.js +43 -5
- package/dist/Guard.js.map +1 -1
- package/dist/Http.d.ts +1 -1
- package/dist/Http.d.ts.map +1 -1
- package/dist/Http.js +22 -619
- package/dist/Http.js.map +1 -1
- package/dist/HttpCache.d.ts +10 -0
- package/dist/HttpCache.d.ts.map +1 -0
- package/dist/HttpCache.js +384 -0
- package/dist/HttpCache.js.map +1 -0
- package/dist/HttpGet.d.ts +5 -26
- package/dist/HttpGet.d.ts.map +1 -1
- package/dist/HttpGet.js +30 -63
- package/dist/HttpGet.js.map +1 -1
- package/dist/HttpRequester.d.ts +4 -15
- package/dist/HttpRequester.d.ts.map +1 -1
- package/dist/HttpRequester.js +31 -49
- package/dist/HttpRequester.js.map +1 -1
- package/dist/HttpShared.d.ts +42 -0
- package/dist/HttpShared.d.ts.map +1 -0
- package/dist/HttpShared.js +244 -0
- package/dist/HttpShared.js.map +1 -0
- package/dist/WebFetcher.d.ts +2 -7
- package/dist/WebFetcher.d.ts.map +1 -1
- package/dist/WebFetcher.js +14 -26
- package/dist/WebFetcher.js.map +1 -1
- package/dist/Ws.d.ts.map +1 -1
- package/dist/Ws.js +11 -7
- package/dist/Ws.js.map +1 -1
- package/dist/index.d.ts +0 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -5
- package/dist/index.js.map +1 -1
- package/docs/https.md +2 -2
- package/docs/wss.md +1 -1
- package/package.json +5 -5
- package/dist/Config.d.ts +0 -5
- package/dist/Config.d.ts.map +0 -1
- package/dist/Config.js +0 -13
- package/dist/Config.js.map +0 -1
- package/dist/ContentType.d.ts +0 -2
- package/dist/ContentType.d.ts.map +0 -1
- package/dist/ContentType.js +0 -15
- package/dist/ContentType.js.map +0 -1
- package/dist/ErrorDetail.d.ts +0 -5
- package/dist/ErrorDetail.d.ts.map +0 -1
- package/dist/ErrorDetail.js +0 -5
- package/dist/ErrorDetail.js.map +0 -1
- package/dist/HostPolicy.d.ts +0 -11
- package/dist/HostPolicy.d.ts.map +0 -1
- package/dist/HostPolicy.js +0 -58
- package/dist/HostPolicy.js.map +0 -1
- package/dist/LiveAcquisitions.d.ts +0 -8
- package/dist/LiveAcquisitions.d.ts.map +0 -1
- package/dist/LiveAcquisitions.js +0 -30
- package/dist/LiveAcquisitions.js.map +0 -1
- package/dist/http-names.d.ts +0 -5
- package/dist/http-names.d.ts.map +0 -1
- package/dist/http-names.js +0 -6
- package/dist/http-names.js.map +0 -1
package/dist/Http.js
CHANGED
|
@@ -1,142 +1,13 @@
|
|
|
1
|
-
// HTTP(S) handler. Operation-to-method semantics live in {§
|
|
1
|
+
// HTTP(S) handler. Operation-to-method semantics live in {§http-manifest};
|
|
2
2
|
// acquisition, materialization, publication, and query flow live in
|
|
3
|
-
// {§http-lifecycle}. The implementation depends only on SchemeCtx capabilities.
|
|
3
|
+
// {§http-lifecycle} and {§http-status}. The implementation depends only on SchemeCtx capabilities.
|
|
4
4
|
import { Validator } from "@plurnk/plurnk-contracts";
|
|
5
|
-
import {
|
|
6
|
-
import { MetadataOptions, MimetypeClassifier, NetworkAddress, ProjectionInputLimitError, Results } from "@plurnk/plurnk-schemes";
|
|
5
|
+
import { MetadataOptions, NetworkAddress } from "@plurnk/plurnk-schemes";
|
|
7
6
|
import { readFile } from "node:fs/promises";
|
|
8
|
-
import ErrorDetail from "./ErrorDetail.js";
|
|
9
|
-
import WebFetcher, { CACHE_VARIANT_HEADER, MATERIALIZER_ID_HEADER, PROJECTION_ID_HEADER, cacheVariantEvidence, classifyCacheVariant } from "./WebFetcher.js";
|
|
10
|
-
import { responseMimetype } from "./ContentType.js";
|
|
11
|
-
import { requireNonNegativeIntegerEnv as requireNumEnv } from "./Config.js";
|
|
12
|
-
import { BODY, FETCHED_AT, HEADER, REQUEST_METHOD } from "./http-names.js";
|
|
13
7
|
import HttpGet from "./HttpGet.js";
|
|
14
8
|
import HttpRequester from "./HttpRequester.js";
|
|
15
|
-
import LiveAcquisitions from "./
|
|
16
|
-
import
|
|
17
|
-
// The channel the response body streams into, and the header metadata channel.
|
|
18
|
-
// Package-owned metadata appended after untrusted origin headers. Readers take
|
|
19
|
-
// the last value so an origin using the same field name cannot override it.
|
|
20
|
-
// {§http-outbound-proposes} — the only methods this scheme proposes, and so the only ones a
|
|
21
|
-
// settlement may perform.
|
|
22
|
-
const OUTBOUND_METHODS = new Set(["POST", "PUT", "DELETE"]);
|
|
23
|
-
const DELTA_SECONDS_LIMIT = 2147483648n;
|
|
24
|
-
const HEURISTIC_CACHE_STATUSES = new Set([200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501]);
|
|
25
|
-
const BODY_PROCESSING_FIELDS = new Set([
|
|
26
|
-
"content-encoding",
|
|
27
|
-
"content-length",
|
|
28
|
-
"content-range",
|
|
29
|
-
"content-type",
|
|
30
|
-
]);
|
|
31
|
-
const splitHttpList = (value) => {
|
|
32
|
-
const members = [];
|
|
33
|
-
let start = 0;
|
|
34
|
-
let quoted = false;
|
|
35
|
-
let escaped = false;
|
|
36
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
37
|
-
const character = value[index];
|
|
38
|
-
if (quoted) {
|
|
39
|
-
if (escaped)
|
|
40
|
-
escaped = false;
|
|
41
|
-
else if (character === "\\")
|
|
42
|
-
escaped = true;
|
|
43
|
-
else if (character === '"')
|
|
44
|
-
quoted = false;
|
|
45
|
-
}
|
|
46
|
-
else if (character === '"') {
|
|
47
|
-
quoted = true;
|
|
48
|
-
}
|
|
49
|
-
else if (character === ",") {
|
|
50
|
-
members.push(value.slice(start, index).trim());
|
|
51
|
-
start = index + 1;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
members.push(value.slice(start).trim());
|
|
55
|
-
return members.filter((member) => member.length > 0);
|
|
56
|
-
};
|
|
57
|
-
const cacheDirective = (member) => {
|
|
58
|
-
const equals = member.indexOf("=");
|
|
59
|
-
const name = member.slice(0, equals < 0 ? undefined : equals).trim().toLowerCase();
|
|
60
|
-
if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name))
|
|
61
|
-
return null;
|
|
62
|
-
if (equals < 0)
|
|
63
|
-
return { name };
|
|
64
|
-
return { name, argument: member.slice(equals + 1).trim() };
|
|
65
|
-
};
|
|
66
|
-
const deltaMilliseconds = (argument) => {
|
|
67
|
-
if (argument === undefined)
|
|
68
|
-
return null;
|
|
69
|
-
const match = /^(?:([0-9]+)|"([0-9]+)")$/.exec(argument);
|
|
70
|
-
const digits = match?.[1] ?? match?.[2];
|
|
71
|
-
if (digits === undefined)
|
|
72
|
-
return null;
|
|
73
|
-
const seconds = BigInt(digits);
|
|
74
|
-
return Number(seconds > DELTA_SECONDS_LIMIT ? DELTA_SECONDS_LIMIT : seconds) * 1000;
|
|
75
|
-
};
|
|
76
|
-
const lastHeaderValue = (header, name) => {
|
|
77
|
-
const lines = header.split(/\r?\n/);
|
|
78
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
79
|
-
const line = lines[index];
|
|
80
|
-
const colon = line.indexOf(":");
|
|
81
|
-
if (colon < 0 || line.slice(0, colon).trim().toLowerCase() !== name)
|
|
82
|
-
continue;
|
|
83
|
-
return line.slice(colon + 1).trim();
|
|
84
|
-
}
|
|
85
|
-
return undefined;
|
|
86
|
-
};
|
|
87
|
-
const entityTag = (value) => {
|
|
88
|
-
const match = /^(W\/)?"([\x21\x23-\x7e\x80-\xff]*)"$/.exec(value);
|
|
89
|
-
return match === null
|
|
90
|
-
? null
|
|
91
|
-
: { weak: match[1] !== undefined, opaque: match[2] };
|
|
92
|
-
};
|
|
93
|
-
const MONTHS = [
|
|
94
|
-
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
95
|
-
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
96
|
-
];
|
|
97
|
-
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
98
|
-
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$/;
|
|
99
|
-
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$/;
|
|
100
|
-
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})$/;
|
|
101
|
-
const utcHttpDate = (weekday, day, month, year, hour, minute, second) => {
|
|
102
|
-
const monthIndex = MONTHS.indexOf(month);
|
|
103
|
-
const weekdayIndex = WEEKDAYS.indexOf(weekday.slice(0, 3));
|
|
104
|
-
const numericDay = Number(day);
|
|
105
|
-
const numericHour = Number(hour);
|
|
106
|
-
const numericMinute = Number(minute);
|
|
107
|
-
const numericSecond = Number(second);
|
|
108
|
-
if (monthIndex < 0 || weekdayIndex < 0)
|
|
109
|
-
return null;
|
|
110
|
-
const date = new Date(0);
|
|
111
|
-
date.setUTCFullYear(year, monthIndex, numericDay);
|
|
112
|
-
date.setUTCHours(numericHour, numericMinute, numericSecond, 0);
|
|
113
|
-
return date.getUTCFullYear() === year
|
|
114
|
-
&& date.getUTCMonth() === monthIndex
|
|
115
|
-
&& date.getUTCDate() === numericDay
|
|
116
|
-
&& date.getUTCHours() === numericHour
|
|
117
|
-
&& date.getUTCMinutes() === numericMinute
|
|
118
|
-
&& date.getUTCSeconds() === numericSecond
|
|
119
|
-
&& date.getUTCDay() === weekdayIndex
|
|
120
|
-
? date.getTime()
|
|
121
|
-
: null;
|
|
122
|
-
};
|
|
123
|
-
const httpDate = (value, now = Date.now()) => {
|
|
124
|
-
const preferred = IMF_FIXDATE.exec(value);
|
|
125
|
-
if (preferred !== null) {
|
|
126
|
-
return utcHttpDate(preferred[1], preferred[2], preferred[3], Number(preferred[4]), preferred[5], preferred[6], preferred[7]);
|
|
127
|
-
}
|
|
128
|
-
const rfc850 = RFC850_DATE.exec(value);
|
|
129
|
-
if (rfc850 !== null) {
|
|
130
|
-
let year = 2000 + Number(rfc850[4]);
|
|
131
|
-
if (year > new Date(now).getUTCFullYear() + 50)
|
|
132
|
-
year -= 100;
|
|
133
|
-
return utcHttpDate(rfc850[1], rfc850[2], rfc850[3], year, rfc850[5], rfc850[6], rfc850[7]);
|
|
134
|
-
}
|
|
135
|
-
const asctime = ASCTIME_DATE.exec(value);
|
|
136
|
-
return asctime === null
|
|
137
|
-
? null
|
|
138
|
-
: utcHttpDate(asctime[1], asctime[3] ?? asctime[4], asctime[2], Number(asctime[8]), asctime[5], asctime[6], asctime[7]);
|
|
139
|
-
};
|
|
9
|
+
import { BODY, HTTP_CHANNELS, LiveAcquisitions, OUTBOUND_METHODS, httpAddress, httpBad, httpPassthrough, } from "./HttpShared.js";
|
|
10
|
+
import WebFetcher from "./WebFetcher.js";
|
|
140
11
|
// The package-shipped model teaching is loaded verbatim and fails hard if absent.
|
|
141
12
|
// The relative path is identical from src/ during development and dist/ after build.
|
|
142
13
|
const documentation = await readFile(new URL("../docs/https.md", import.meta.url), "utf-8");
|
|
@@ -151,9 +22,9 @@ export default class Http {
|
|
|
151
22
|
// body is retyped per-call to the real response Content-Type;
|
|
152
23
|
// octet-stream is the honest "unknown until fetched". header is always the status
|
|
153
24
|
// line + headers (text/plain).
|
|
154
|
-
// {§
|
|
25
|
+
// {§channel-selection} — body is the response as served (a page's server source); readable
|
|
155
26
|
// is curated Markdown or binary facts; header is transport evidence.
|
|
156
|
-
channels: {
|
|
27
|
+
channels: { ...HTTP_CHANNELS },
|
|
157
28
|
defaultChannel: BODY,
|
|
158
29
|
category: "data",
|
|
159
30
|
writableBy: ["model", "client"],
|
|
@@ -165,9 +36,9 @@ export default class Http {
|
|
|
165
36
|
traits: ["web"],
|
|
166
37
|
};
|
|
167
38
|
#webFetcher;
|
|
168
|
-
// {§
|
|
39
|
+
// {§scheme-operation-dispatch}: published for the entry sink; reached through the registry.
|
|
169
40
|
webMaterializer;
|
|
170
|
-
// {§http-
|
|
41
|
+
// {§http-lifecycle} — one opportunistic origin-companion attempt per TTL
|
|
171
42
|
// window; success and failure share the timer so a 404 does not re-probe
|
|
172
43
|
// on every READ.
|
|
173
44
|
#get;
|
|
@@ -176,8 +47,8 @@ export default class Http {
|
|
|
176
47
|
constructor() {
|
|
177
48
|
this.#webFetcher = new WebFetcher();
|
|
178
49
|
this.webMaterializer = this.#webFetcher;
|
|
179
|
-
this.#get = new HttpGet({ live: this.#live, webFetcher: this.#webFetcher
|
|
180
|
-
this.#requester = new HttpRequester({ live: this.#live, manifest: Http.manifest
|
|
50
|
+
this.#get = new HttpGet({ live: this.#live, webFetcher: this.#webFetcher });
|
|
51
|
+
this.#requester = new HttpRequester({ live: this.#live, manifest: Http.manifest });
|
|
181
52
|
}
|
|
182
53
|
async ready() {
|
|
183
54
|
WebFetcher.validateConfiguration();
|
|
@@ -187,7 +58,7 @@ export default class Http {
|
|
|
187
58
|
// returned 102 for a retained SSE response.
|
|
188
59
|
async prepareRepresentation(request, ctx) {
|
|
189
60
|
if (request.target.kind !== "url") {
|
|
190
|
-
return
|
|
61
|
+
return httpBad(400, "http", "bad-target", "READ requires an http(s):// URL target.", {
|
|
191
62
|
stage: "target-validation",
|
|
192
63
|
recovery: "Provide an http(s):// URL target.",
|
|
193
64
|
retryable: false,
|
|
@@ -199,7 +70,7 @@ export default class Http {
|
|
|
199
70
|
// remote resource - reject rather than silently ignore the model's intent.
|
|
200
71
|
async editBatch(statements, _ctx) {
|
|
201
72
|
if (statements.length !== 1) {
|
|
202
|
-
return
|
|
73
|
+
return httpBad(409, "http", "non-atomic-edit-batch", `${statements.length} EDIT statements cannot be committed atomically to one remote resource.`, {
|
|
203
74
|
statements: statements.length,
|
|
204
75
|
stage: "mutation",
|
|
205
76
|
recovery: "Submit one whole-resource EDIT.",
|
|
@@ -208,14 +79,14 @@ export default class Http {
|
|
|
208
79
|
}
|
|
209
80
|
const statement = statements[0];
|
|
210
81
|
if (statement.target === null || statement.target.kind !== "url") {
|
|
211
|
-
return
|
|
82
|
+
return httpBad(400, "http", "bad-target", "EDIT requires an http(s):// URL target.", {
|
|
212
83
|
stage: "target-validation",
|
|
213
84
|
recovery: "Provide an http(s):// URL target.",
|
|
214
85
|
retryable: false,
|
|
215
86
|
});
|
|
216
87
|
}
|
|
217
88
|
if (statement.lineMarker !== null) {
|
|
218
|
-
return
|
|
89
|
+
return httpBad(400, "http", "line-edit-unsupported", "HTTP EDIT replaces the whole remote resource and does not accept a line range.", {
|
|
219
90
|
stage: "mutation",
|
|
220
91
|
recovery: "Remove the line range and submit the complete replacement body.",
|
|
221
92
|
retryable: false,
|
|
@@ -226,12 +97,12 @@ export default class Http {
|
|
|
226
97
|
async edit(statement, ctx) {
|
|
227
98
|
return this.editBatch([statement], ctx);
|
|
228
99
|
}
|
|
229
|
-
// {§
|
|
100
|
+
// {§revalidation} — KILL follows the entry rule: a live acquisition of the address is
|
|
230
101
|
// cancelled, otherwise the stored response is forgotten. The remote DELETE is its own
|
|
231
102
|
// spelling, `KILL (https://…) [{"remote": true}]`; any other metadata keys are its headers.
|
|
232
103
|
async kill(statement, ctx) {
|
|
233
104
|
if (statement.target === null || statement.target.kind !== "url") {
|
|
234
|
-
return
|
|
105
|
+
return httpBad(400, "http", "bad-target", "KILL requires an http(s):// URL target.", {
|
|
235
106
|
stage: "target-validation",
|
|
236
107
|
recovery: "Provide an http(s):// URL target.",
|
|
237
108
|
retryable: false,
|
|
@@ -244,16 +115,16 @@ export default class Http {
|
|
|
244
115
|
// The `remote` spelling is stripped in applyResolution, where the request is actually made.
|
|
245
116
|
if (read.options.remote === true)
|
|
246
117
|
return Http.#propose("DELETE", statement.target);
|
|
247
|
-
const address =
|
|
118
|
+
const address = httpAddress(statement.target);
|
|
248
119
|
if (!(address instanceof NetworkAddress))
|
|
249
120
|
return address;
|
|
250
121
|
if (this.#live.cancel(LiveAcquisitions.key(ctx.workspaceId, address.url))) {
|
|
251
122
|
// The owner settles itself as cancelled through its aborted signal.
|
|
252
123
|
return { shape: "passthrough", status: 200 };
|
|
253
124
|
}
|
|
254
|
-
return
|
|
125
|
+
return httpPassthrough(await ctx.entries.delete(address.pathname));
|
|
255
126
|
}
|
|
256
|
-
// {§http-
|
|
127
|
+
// {§http-manifest} — a request that changes a remote resource declares the host
|
|
257
128
|
// effect and proposes; `PLURNK_SERVICE_EFFECT_HOST` decides whether that settles by consent
|
|
258
129
|
// or runs unattended. The headers stay OUT of attrs: they ride the statement metadata across
|
|
259
130
|
// the pause, so an `Authorization` value never enters durable proposal evidence.
|
|
@@ -291,10 +162,10 @@ export default class Http {
|
|
|
291
162
|
}
|
|
292
163
|
return this.#requester.request(url, metadata, ctx, method, body);
|
|
293
164
|
}
|
|
294
|
-
// {§
|
|
165
|
+
// {§op-shapes} — SEND is messaging; lifecycle operations never reach a scheme.
|
|
295
166
|
async send(statement, _ctx) {
|
|
296
167
|
if (statement.target === null || statement.target.kind !== "url") {
|
|
297
|
-
return
|
|
168
|
+
return httpBad(400, "http", "bad-target", "SEND requires an http(s):// URL target.", {
|
|
298
169
|
stage: "target-validation",
|
|
299
170
|
recovery: "Provide an http(s):// URL target.",
|
|
300
171
|
retryable: false,
|
|
@@ -302,473 +173,5 @@ export default class Http {
|
|
|
302
173
|
}
|
|
303
174
|
return Http.#propose("POST", statement.target, statement.body?.raw ?? "");
|
|
304
175
|
}
|
|
305
|
-
// {§http-manifest}/{§http-lifecycle} Seed the declared channel shape before
|
|
306
|
-
// open(); notifyChunk later welds acquired content to its actual mimetype.
|
|
307
|
-
static #seedEntry() {
|
|
308
|
-
const channels = Object.fromEntries(Object.entries(Http.manifest.channels).map(([name, mimetype]) => [name, {
|
|
309
|
-
content: "",
|
|
310
|
-
mimetype,
|
|
311
|
-
state: "active",
|
|
312
|
-
}]));
|
|
313
|
-
return { channels };
|
|
314
|
-
}
|
|
315
|
-
// {§revalidation} The body and header must have settled successfully. An
|
|
316
|
-
// auxiliary channel may have terminally failed without invalidating body.
|
|
317
|
-
static #representationComplete(entry) {
|
|
318
|
-
const bodyState = entry.channels[BODY]?.state;
|
|
319
|
-
const headerState = entry.channels[HEADER]?.state;
|
|
320
|
-
const successful = (state) => state === "static" || state === "closed";
|
|
321
|
-
const terminal = (state) => successful(state) || state === "errored";
|
|
322
|
-
return successful(bodyState)
|
|
323
|
-
&& successful(headerState)
|
|
324
|
-
&& Object.values(entry.channels).every(({ state }) => terminal(state));
|
|
325
|
-
}
|
|
326
|
-
// {§revalidation} Direct READ and exact FIND share the complete reusable
|
|
327
|
-
// representation predicate; freshness only decides how READ uses a match.
|
|
328
|
-
static async #reusableGetRepresentation(entry, requestHeaders, projection) {
|
|
329
|
-
const body = entry.channels[BODY];
|
|
330
|
-
const header = entry.channels[HEADER];
|
|
331
|
-
if (body === undefined || header === undefined)
|
|
332
|
-
return false;
|
|
333
|
-
return Http.#representationComplete(entry)
|
|
334
|
-
&& Http.#requestMethod(header.content) === "GET"
|
|
335
|
-
&& requestHeaders.length === 0
|
|
336
|
-
&& Http.#packageHeaderValue(header.content, CACHE_VARIANT_HEADER) === "default"
|
|
337
|
-
&& Http.#storedCachePolicy(header.content).cacheable
|
|
338
|
-
&& await Http.#projectionCurrent(header.content, projection);
|
|
339
|
-
}
|
|
340
|
-
// {§revalidation} Origin headers come first; authoritative package method
|
|
341
|
-
// and acquisition metadata are appended last.
|
|
342
|
-
static #responseHeader(method, status, statusText, responseHeaders, requestHeaders) {
|
|
343
|
-
const lines = [`HTTP ${status} ${statusText}`];
|
|
344
|
-
for (const [k, v] of responseHeaders)
|
|
345
|
-
lines.push(`${k}: ${v}`);
|
|
346
|
-
lines.push(`${REQUEST_METHOD}: ${method}`);
|
|
347
|
-
lines.push(`${FETCHED_AT}: ${new Date().toISOString()}`);
|
|
348
|
-
lines.push(cacheVariantEvidence(classifyCacheVariant(requestHeaders, responseHeaders)));
|
|
349
|
-
return lines.join("\n");
|
|
350
|
-
}
|
|
351
|
-
static async #writeProjectionIdentity(subscription, identity) {
|
|
352
|
-
await subscription.notifyChunk(HEADER, `\n${WebFetcher.projectionEvidence(identity)}`, "text/plain");
|
|
353
|
-
}
|
|
354
|
-
// Drain an acquired SSE body and settle its retained subscription. The READ
|
|
355
|
-
// has already returned 102, so terminal failure is durable stream state.
|
|
356
|
-
static async #settleEventStream(subscription, response, options) {
|
|
357
|
-
try {
|
|
358
|
-
const events = await Http.#streamEvents(subscription, response);
|
|
359
|
-
if (options.signal.aborted) {
|
|
360
|
-
const result = Http.#cancelled(options.url, options.method);
|
|
361
|
-
await subscription.close(result, result.problem?.detail);
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
await subscription.close({ status: 200 }, `SSE stream; ${events} events`);
|
|
365
|
-
}
|
|
366
|
-
catch (error) {
|
|
367
|
-
if (options.signal.aborted) {
|
|
368
|
-
const result = Http.#cancelled(options.url, options.method);
|
|
369
|
-
await subscription.close(result, result.problem?.detail);
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
console.error("HTTP SSE stream failed", { method: options.method, url: options.url, error });
|
|
373
|
-
const cause = ErrorDetail.preview(error);
|
|
374
|
-
const reason = `HTTP ${options.method} ${options.url} failed: ${cause}`;
|
|
375
|
-
const result = Http.#bad(502, "http", "fetch-failed", reason, {
|
|
376
|
-
target: options.url,
|
|
377
|
-
method: options.method,
|
|
378
|
-
stage: "transfer",
|
|
379
|
-
retryable: false,
|
|
380
|
-
});
|
|
381
|
-
await subscription.close(result, reason);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
// event/id/retry/comments remain transport metadata: this projection
|
|
385
|
-
// publishes event data, not the wire, and does not reconnect.
|
|
386
|
-
static async #streamEvents(subscription, response) {
|
|
387
|
-
if (response.body === null)
|
|
388
|
-
return 0;
|
|
389
|
-
const decoder = new TextDecoder();
|
|
390
|
-
const pending = [];
|
|
391
|
-
let fatal = null;
|
|
392
|
-
let events = 0;
|
|
393
|
-
const parser = createParser({
|
|
394
|
-
maxBufferSize: requireNumEnv("PLURNK_SCHEMES_HTTP_SSE_MAX_BUFFER_CHARS"),
|
|
395
|
-
onEvent: ({ data }) => pending.push(data),
|
|
396
|
-
// Unknown fields and invalid retry hints are irrelevant to this
|
|
397
|
-
// non-reconnecting data projection. Buffer exhaustion is fatal.
|
|
398
|
-
onError: (error) => {
|
|
399
|
-
if (error.type === "max-buffer-size-exceeded")
|
|
400
|
-
fatal = error;
|
|
401
|
-
},
|
|
402
|
-
});
|
|
403
|
-
const publish = async () => {
|
|
404
|
-
for (const data of pending.splice(0)) {
|
|
405
|
-
events += 1;
|
|
406
|
-
await subscription.notifyChunk(BODY, `${data}\n`, "text/plain");
|
|
407
|
-
}
|
|
408
|
-
};
|
|
409
|
-
for await (const chunk of response.body) {
|
|
410
|
-
parser.feed(decoder.decode(chunk, { stream: true }));
|
|
411
|
-
if (fatal !== null)
|
|
412
|
-
throw fatal;
|
|
413
|
-
await publish();
|
|
414
|
-
}
|
|
415
|
-
const tail = decoder.decode();
|
|
416
|
-
if (tail.length > 0)
|
|
417
|
-
parser.feed(tail);
|
|
418
|
-
parser.reset({ consume: true });
|
|
419
|
-
if (fatal !== null)
|
|
420
|
-
throw fatal;
|
|
421
|
-
await publish();
|
|
422
|
-
return events;
|
|
423
|
-
}
|
|
424
|
-
static #address(target) {
|
|
425
|
-
let address;
|
|
426
|
-
try {
|
|
427
|
-
address = NetworkAddress.from(target);
|
|
428
|
-
}
|
|
429
|
-
catch {
|
|
430
|
-
return Http.#bad(400, "http", "bad-target", "HTTP operations require an http(s):// URL with an authority.", {
|
|
431
|
-
stage: "target-validation",
|
|
432
|
-
recovery: "Provide an http(s):// URL with a host.",
|
|
433
|
-
retryable: false,
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
if (address.scheme !== "http" && address.scheme !== "https") {
|
|
437
|
-
return Http.#bad(400, "http", "bad-target", "HTTP operations require an http(s):// URL target.", {
|
|
438
|
-
stage: "target-validation",
|
|
439
|
-
recovery: "Provide an http(s):// URL target.",
|
|
440
|
-
retryable: false,
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
if (address.hasCredentials) {
|
|
444
|
-
return Http.#bad(400, "http", "userinfo-not-allowed", "HTTP URL userinfo is not allowed.", {
|
|
445
|
-
target: address.url,
|
|
446
|
-
stage: "target-validation",
|
|
447
|
-
recovery: "Remove credentials from the URL and use request metadata where authorization is required.",
|
|
448
|
-
retryable: false,
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
// {§http-host-policy} — an operator-confined deployment refuses other hosts before any I/O.
|
|
452
|
-
if (!HostPolicy.permits(address.url)) {
|
|
453
|
-
return Http.#bad(403, "http", "host-not-permitted", `${new URL(address.url).hostname} is outside the operator's web host policy.`, {
|
|
454
|
-
target: address.url,
|
|
455
|
-
stage: "target-validation",
|
|
456
|
-
retryable: false,
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
return address;
|
|
460
|
-
}
|
|
461
|
-
// {§scheme-metadata-modifier} The request's [metadata] object is its header map:
|
|
462
|
-
// every key is a header name and every value a header string. `remote` on KILL
|
|
463
|
-
// is the one non-header key, consumed before the headers are read.
|
|
464
|
-
static #requestHeaders(metadata) {
|
|
465
|
-
const read = MetadataOptions.parse(metadata, "scheme:http");
|
|
466
|
-
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- a metadata refusal has no shape; the method's result type names the success's passthrough shape
|
|
467
|
-
if ("failure" in read)
|
|
468
|
-
return read.failure;
|
|
469
|
-
const headers = [];
|
|
470
|
-
for (const [name, value] of Object.entries(read.options)) {
|
|
471
|
-
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u.test(name)) {
|
|
472
|
-
return Http.#bad(400, "http", "metadata-header-name", `HTTP metadata key '${name}' is not a valid header name.`, { retryable: false });
|
|
473
|
-
}
|
|
474
|
-
if (typeof value !== "string") {
|
|
475
|
-
return Http.#bad(400, "http", "metadata-header-shape", `HTTP header '${name}' requires a string value.`, { retryable: false });
|
|
476
|
-
}
|
|
477
|
-
headers.push([name, value.trim()]);
|
|
478
|
-
}
|
|
479
|
-
return headers;
|
|
480
|
-
}
|
|
481
|
-
// {§revalidation} Validation-free reuse requires both the operator's local
|
|
482
|
-
// ceiling and the origin's response policy to remain live.
|
|
483
|
-
static #fresh(header, now = Date.now()) {
|
|
484
|
-
const fetchedAt = Http.#fetchedAt(header);
|
|
485
|
-
const ttl = requireNumEnv("PLURNK_SCHEMES_HTTP_TTL_MS");
|
|
486
|
-
if (fetchedAt === undefined || ttl <= 0)
|
|
487
|
-
return false;
|
|
488
|
-
const residentTime = Math.max(0, now - fetchedAt);
|
|
489
|
-
if (residentTime >= ttl)
|
|
490
|
-
return false;
|
|
491
|
-
const policy = Http.#storedCachePolicy(header);
|
|
492
|
-
if (!policy.cacheable || policy.noCache)
|
|
493
|
-
return false;
|
|
494
|
-
return policy.freshnessLifetimeMs === undefined
|
|
495
|
-
|| Http.#currentAge(header, fetchedAt, now) < policy.freshnessLifetimeMs;
|
|
496
|
-
}
|
|
497
|
-
// Package metadata is appended after origin headers, so the last value wins.
|
|
498
|
-
static #fetchedAt(priorHeader) {
|
|
499
|
-
const value = lastHeaderValue(priorHeader, FETCHED_AT);
|
|
500
|
-
if (value === undefined)
|
|
501
|
-
return undefined;
|
|
502
|
-
const ms = Date.parse(value);
|
|
503
|
-
return Number.isNaN(ms) ? undefined : ms;
|
|
504
|
-
}
|
|
505
|
-
static #headerField(line) {
|
|
506
|
-
const colon = line.indexOf(":");
|
|
507
|
-
if (colon < 0)
|
|
508
|
-
return null;
|
|
509
|
-
const name = line.slice(0, colon).trim().toLowerCase();
|
|
510
|
-
if (name.length === 0)
|
|
511
|
-
return null;
|
|
512
|
-
return { name, value: line.slice(colon + 1).trim(), line };
|
|
513
|
-
}
|
|
514
|
-
static #packageStart(lines) {
|
|
515
|
-
let stampIndex = -1;
|
|
516
|
-
for (const [index, line] of lines.entries()) {
|
|
517
|
-
if (Http.#headerField(line)?.name === FETCHED_AT)
|
|
518
|
-
stampIndex = index;
|
|
519
|
-
}
|
|
520
|
-
if (stampIndex < 0)
|
|
521
|
-
return lines.length;
|
|
522
|
-
return Http.#headerField(lines[stampIndex - 1] ?? "")?.name === REQUEST_METHOD
|
|
523
|
-
? stampIndex - 1
|
|
524
|
-
: stampIndex;
|
|
525
|
-
}
|
|
526
|
-
static #originFields(header) {
|
|
527
|
-
const lines = header.split(/\r?\n/);
|
|
528
|
-
return lines.slice(1, Http.#packageStart(lines))
|
|
529
|
-
.map((line) => Http.#headerField(line))
|
|
530
|
-
.filter((field) => field !== null);
|
|
531
|
-
}
|
|
532
|
-
static #originValues(fields, name) {
|
|
533
|
-
return fields.filter((field) => field.name === name).map(({ value }) => value);
|
|
534
|
-
}
|
|
535
|
-
static #storedCachePolicy(header) {
|
|
536
|
-
const fields = Http.#originFields(header);
|
|
537
|
-
const directives = Http.#originValues(fields, "cache-control")
|
|
538
|
-
.flatMap((value) => splitHttpList(value))
|
|
539
|
-
.map((member) => cacheDirective(member))
|
|
540
|
-
.filter((directive) => directive !== null);
|
|
541
|
-
const sharedMaxAges = directives.filter(({ name }) => name === "s-maxage");
|
|
542
|
-
const maxAges = sharedMaxAges.length > 0 ? sharedMaxAges
|
|
543
|
-
: directives.filter(({ name }) => name === "max-age");
|
|
544
|
-
let freshnessLifetimeMs;
|
|
545
|
-
if (maxAges.length > 0) {
|
|
546
|
-
freshnessLifetimeMs = maxAges.length === 1
|
|
547
|
-
? deltaMilliseconds(maxAges[0].argument) ?? 0
|
|
548
|
-
: 0;
|
|
549
|
-
}
|
|
550
|
-
else {
|
|
551
|
-
const expiresValues = Http.#originValues(fields, "expires");
|
|
552
|
-
if (expiresValues.length > 0) {
|
|
553
|
-
const expires = expiresValues.length === 1
|
|
554
|
-
? httpDate(expiresValues[0])
|
|
555
|
-
: null;
|
|
556
|
-
const fetchedAt = Http.#fetchedAt(header);
|
|
557
|
-
if (expires === null || fetchedAt === undefined) {
|
|
558
|
-
freshnessLifetimeMs = 0;
|
|
559
|
-
}
|
|
560
|
-
else {
|
|
561
|
-
const dateValues = Http.#originValues(fields, "date");
|
|
562
|
-
const parsedDate = dateValues.length === 1
|
|
563
|
-
? httpDate(dateValues[0])
|
|
564
|
-
: null;
|
|
565
|
-
const generatedAt = parsedDate ?? fetchedAt;
|
|
566
|
-
freshnessLifetimeMs = Math.max(0, expires - generatedAt);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
const status = Number(/^HTTP ([0-9]{3})(?:\s|$)/.exec(header)?.[1]);
|
|
571
|
-
const explicitPermission = freshnessLifetimeMs !== undefined
|
|
572
|
-
|| directives.some(({ name, argument }) => name === "public" && argument === undefined);
|
|
573
|
-
return {
|
|
574
|
-
cacheable: status >= 200 && status <= 599 && status !== 206
|
|
575
|
-
&& !directives.some(({ name }) => name === "no-store" || name === "private")
|
|
576
|
-
&& (HEURISTIC_CACHE_STATUSES.has(status) || explicitPermission),
|
|
577
|
-
noCache: directives.some(({ name }) => name === "no-cache"),
|
|
578
|
-
...(freshnessLifetimeMs === undefined ? {} : { freshnessLifetimeMs }),
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
|
-
static #currentAge(header, fetchedAt, now) {
|
|
582
|
-
const fields = Http.#originFields(header);
|
|
583
|
-
const dateValues = Http.#originValues(fields, "date");
|
|
584
|
-
const date = dateValues.length === 1 ? httpDate(dateValues[0]) : null;
|
|
585
|
-
const apparentAge = date === null ? 0 : Math.max(0, fetchedAt - date);
|
|
586
|
-
const ageMember = Http.#originValues(fields, "age").flatMap(splitHttpList)[0];
|
|
587
|
-
const ageValue = deltaMilliseconds(ageMember) ?? 0;
|
|
588
|
-
return Math.max(apparentAge, ageValue) + Math.max(0, now - fetchedAt);
|
|
589
|
-
}
|
|
590
|
-
static #requestMethod(priorHeader) {
|
|
591
|
-
return lastHeaderValue(priorHeader, REQUEST_METHOD)?.toUpperCase();
|
|
592
|
-
}
|
|
593
|
-
// Package evidence is authoritative only after the package acquisition
|
|
594
|
-
// stamp. An origin field with the same name remains inert.
|
|
595
|
-
static #packageHeaderValue(priorHeader, field) {
|
|
596
|
-
const lines = priorHeader.split(/\r?\n/);
|
|
597
|
-
let stampIndex = -1;
|
|
598
|
-
for (const [index, line] of lines.entries()) {
|
|
599
|
-
const colon = line.indexOf(":");
|
|
600
|
-
if (colon < 0)
|
|
601
|
-
continue;
|
|
602
|
-
const name = line.slice(0, colon).trim().toLowerCase();
|
|
603
|
-
if (name === FETCHED_AT)
|
|
604
|
-
stampIndex = index;
|
|
605
|
-
}
|
|
606
|
-
if (stampIndex < 0)
|
|
607
|
-
return undefined;
|
|
608
|
-
let value;
|
|
609
|
-
for (const line of lines.slice(stampIndex + 1)) {
|
|
610
|
-
const colon = line.indexOf(":");
|
|
611
|
-
if (colon < 0 || line.slice(0, colon).trim().toLowerCase() !== field)
|
|
612
|
-
continue;
|
|
613
|
-
value = line.slice(colon + 1).trim();
|
|
614
|
-
}
|
|
615
|
-
return value === "" ? undefined : value;
|
|
616
|
-
}
|
|
617
|
-
static #projectionIdentity(priorHeader) {
|
|
618
|
-
return Http.#packageHeaderValue(priorHeader, PROJECTION_ID_HEADER);
|
|
619
|
-
}
|
|
620
|
-
static #materializerIdentity(priorHeader) {
|
|
621
|
-
return Http.#packageHeaderValue(priorHeader, MATERIALIZER_ID_HEADER);
|
|
622
|
-
}
|
|
623
|
-
static #sourceMimetype(header) {
|
|
624
|
-
return responseMimetype(lastHeaderValue(header, "content-type") ?? null);
|
|
625
|
-
}
|
|
626
|
-
static async #projectionCurrent(header, projection) {
|
|
627
|
-
const sourceMimetype = Http.#sourceMimetype(header);
|
|
628
|
-
const isHtml = MimetypeClassifier.isHtml(sourceMimetype);
|
|
629
|
-
const materializerIdentity = Http.#materializerIdentity(header);
|
|
630
|
-
if (materializerIdentity !== undefined) {
|
|
631
|
-
// {§http-materializer-extensions} — a stored body produced by the current
|
|
632
|
-
// configured materializer (or the built-in producers) is current.
|
|
633
|
-
return WebFetcher.materializerCurrent(materializerIdentity);
|
|
634
|
-
}
|
|
635
|
-
if (isHtml && materializerIdentity === undefined)
|
|
636
|
-
return false;
|
|
637
|
-
const storedIdentity = Http.#projectionIdentity(header);
|
|
638
|
-
if (storedIdentity === undefined)
|
|
639
|
-
return !isHtml;
|
|
640
|
-
return await projection.identity(sourceMimetype) === storedIdentity;
|
|
641
|
-
}
|
|
642
|
-
// RFC 9111 §3.2 permits a processed cache to retain the metadata that its
|
|
643
|
-
// stored body depends upon. Other 304 fields update by name; package-owned
|
|
644
|
-
// evidence is then rebuilt after the origin block. {§revalidation}
|
|
645
|
-
static #refreshAfter304(header, responseHeaders, requestHeaders) {
|
|
646
|
-
const lines = header.split(/\r?\n/);
|
|
647
|
-
const origin = Http.#originFields(header);
|
|
648
|
-
const updates = responseHeaders.filter(([name]) => !BODY_PROCESSING_FIELDS.has(name.toLowerCase()));
|
|
649
|
-
const updatedNames = new Set(updates.map(([name]) => name.toLowerCase()));
|
|
650
|
-
const retained = origin.filter(({ name }) => !updatedNames.has(name));
|
|
651
|
-
const merged = [
|
|
652
|
-
...retained.map(({ name, value }) => [name, value]),
|
|
653
|
-
...updates,
|
|
654
|
-
];
|
|
655
|
-
// Materializer/provider evidence headers are extension-owned (unknown names);
|
|
656
|
-
// a 304 does not re-materialize the stored body, so its evidence is
|
|
657
|
-
// retained verbatim beside the rebuilt framework fields. Framework-owned
|
|
658
|
-
// x-plurnk-* fields (cache-variant, materializer/projection ids, method,
|
|
659
|
-
// fetched-at stamp) are rebuilt or omitted, never duplicated stale.
|
|
660
|
-
const rebuilt = [MATERIALIZER_ID_HEADER, PROJECTION_ID_HEADER].flatMap((field) => {
|
|
661
|
-
const value = Http.#packageHeaderValue(header, field);
|
|
662
|
-
return value === undefined ? [] : [`${field}: ${value}`];
|
|
663
|
-
});
|
|
664
|
-
const frameworkOwned = new Set([
|
|
665
|
-
CACHE_VARIANT_HEADER.toLowerCase(),
|
|
666
|
-
MATERIALIZER_ID_HEADER.toLowerCase(),
|
|
667
|
-
PROJECTION_ID_HEADER.toLowerCase(),
|
|
668
|
-
REQUEST_METHOD.toLowerCase(),
|
|
669
|
-
FETCHED_AT.toLowerCase(),
|
|
670
|
-
]);
|
|
671
|
-
const packageEvidence = [
|
|
672
|
-
...rebuilt,
|
|
673
|
-
...lines.slice(1).filter((line) => {
|
|
674
|
-
const colon = line.indexOf(":");
|
|
675
|
-
if (colon <= 0)
|
|
676
|
-
return false;
|
|
677
|
-
const name = line.slice(0, colon).toLowerCase();
|
|
678
|
-
return name.startsWith("x-plurnk-")
|
|
679
|
-
&& !frameworkOwned.has(name)
|
|
680
|
-
&& !updatedNames.has(name);
|
|
681
|
-
}),
|
|
682
|
-
];
|
|
683
|
-
return [
|
|
684
|
-
lines[0] ?? "HTTP 200 OK",
|
|
685
|
-
...retained.map(({ line }) => line),
|
|
686
|
-
...updates.map(([name, value]) => `${name}: ${value}`),
|
|
687
|
-
`${REQUEST_METHOD}: GET`,
|
|
688
|
-
`${FETCHED_AT}: ${new Date().toISOString()}`,
|
|
689
|
-
cacheVariantEvidence(classifyCacheVariant(requestHeaders, merged)),
|
|
690
|
-
...packageEvidence,
|
|
691
|
-
].join("\n");
|
|
692
|
-
}
|
|
693
|
-
static #revalidationCorresponds(priorHeader, responseHeaders) {
|
|
694
|
-
const fields = Http.#originFields(priorHeader);
|
|
695
|
-
const responseTagValue = responseHeaders.get("etag");
|
|
696
|
-
if (responseTagValue !== null) {
|
|
697
|
-
const storedTagValues = Http.#originValues(fields, "etag");
|
|
698
|
-
const responseTag = entityTag(responseTagValue);
|
|
699
|
-
const storedTag = storedTagValues.length === 1
|
|
700
|
-
? entityTag(storedTagValues[0])
|
|
701
|
-
: null;
|
|
702
|
-
if (responseTag === null || storedTag === null)
|
|
703
|
-
return false;
|
|
704
|
-
// {§revalidation}: RFC 9111 §4.3.4 identifies stored responses;
|
|
705
|
-
// it is not the origin's If-None-Match precondition comparison.
|
|
706
|
-
return responseTag.opaque === storedTag.opaque
|
|
707
|
-
&& (responseTag.weak || !storedTag.weak);
|
|
708
|
-
}
|
|
709
|
-
const responseModifiedValue = responseHeaders.get("last-modified");
|
|
710
|
-
const storedModifiedValues = Http.#originValues(fields, "last-modified");
|
|
711
|
-
const responseModified = responseModifiedValue === null
|
|
712
|
-
? null
|
|
713
|
-
: httpDate(responseModifiedValue);
|
|
714
|
-
const storedModified = storedModifiedValues.length === 1
|
|
715
|
-
? httpDate(storedModifiedValues[0])
|
|
716
|
-
: null;
|
|
717
|
-
return responseModified !== null
|
|
718
|
-
&& storedModified !== null
|
|
719
|
-
&& responseModified === storedModified;
|
|
720
|
-
}
|
|
721
|
-
// Conditional-request headers from the prior fetch's stored response headers
|
|
722
|
-
// (the HEADER channel's response evidence): ETag → If-None-Match,
|
|
723
|
-
// Last-Modified → If-Modified-Since. Empty when neither is present — the
|
|
724
|
-
// origin then just 200s with a full body, which is correct.
|
|
725
|
-
static #validators(priorHeader) {
|
|
726
|
-
const fields = Http.#originFields(priorHeader);
|
|
727
|
-
const out = [];
|
|
728
|
-
const etags = Http.#originValues(fields, "etag");
|
|
729
|
-
if (etags.length === 1 && entityTag(etags[0]) !== null) {
|
|
730
|
-
out.push(["If-None-Match", etags[0]]);
|
|
731
|
-
}
|
|
732
|
-
const lastModified = Http.#originValues(fields, "last-modified");
|
|
733
|
-
if (lastModified.length === 1 && httpDate(lastModified[0]) !== null) {
|
|
734
|
-
out.push(["If-Modified-Since", lastModified[0]]);
|
|
735
|
-
}
|
|
736
|
-
return out;
|
|
737
|
-
}
|
|
738
|
-
static #passthrough(result) {
|
|
739
|
-
return Results.assertChannelProducerResult({ ...result, shape: "passthrough" });
|
|
740
|
-
}
|
|
741
|
-
static #cancelled(url, method) {
|
|
742
|
-
return Http.#bad(499, "http", "cancelled", `HTTP ${method} ${url} was cancelled.`, {
|
|
743
|
-
target: url,
|
|
744
|
-
method,
|
|
745
|
-
stage: "acquisition",
|
|
746
|
-
retryable: false,
|
|
747
|
-
});
|
|
748
|
-
}
|
|
749
|
-
static #materializationFailure(url, method, error) {
|
|
750
|
-
if (error.stage === "projection" && error.cause instanceof ProjectionInputLimitError) {
|
|
751
|
-
return Http.#bad(413, "http", "projection-input-limit", `HTTP ${method} ${url} exceeded the ${error.cause.maximumBytes}-byte binary input limit.`, {
|
|
752
|
-
target: url,
|
|
753
|
-
method,
|
|
754
|
-
mimetype: error.cause.mimetype,
|
|
755
|
-
maximumBytes: error.cause.maximumBytes,
|
|
756
|
-
observedBytes: error.cause.observedBytes,
|
|
757
|
-
stage: "projection",
|
|
758
|
-
retryable: false,
|
|
759
|
-
});
|
|
760
|
-
}
|
|
761
|
-
console.error("HTTP materialization failed", { method, url, error });
|
|
762
|
-
return Http.#bad(500, "http", "projection-failed", `HTTP ${method} ${url} response materialization failed.`, {
|
|
763
|
-
target: url,
|
|
764
|
-
method,
|
|
765
|
-
mimetype: error.mimetype,
|
|
766
|
-
stage: "projection",
|
|
767
|
-
retryable: false,
|
|
768
|
-
});
|
|
769
|
-
}
|
|
770
|
-
static #bad(status, scheme, kind, message, extensions = {}) {
|
|
771
|
-
return Results.failure(`scheme:${scheme}`, kind, status, message, { shape: "passthrough" }, extensions);
|
|
772
|
-
}
|
|
773
176
|
}
|
|
774
177
|
//# sourceMappingURL=Http.js.map
|