@plurnk/plurnk-schemes-http 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.defaults +10 -40
- package/README.md +28 -24
- package/SPEC.md +246 -220
- package/dist/Config.d.ts +3 -0
- package/dist/Config.d.ts.map +1 -0
- package/dist/Config.js +18 -0
- package/dist/Config.js.map +1 -0
- package/dist/Guard.d.ts.map +1 -1
- package/dist/Guard.js +2 -3
- package/dist/Guard.js.map +1 -1
- package/dist/Http.d.ts +3 -16
- package/dist/Http.d.ts.map +1 -1
- package/dist/Http.js +206 -229
- package/dist/Http.js.map +1 -1
- package/dist/TavilyExtract.d.ts +35 -0
- package/dist/TavilyExtract.d.ts.map +1 -0
- package/dist/TavilyExtract.js +204 -0
- package/dist/TavilyExtract.js.map +1 -0
- package/dist/WebFetcher.d.ts +58 -24
- package/dist/WebFetcher.d.ts.map +1 -1
- package/dist/WebFetcher.js +485 -108
- package/dist/WebFetcher.js.map +1 -1
- package/dist/Ws.d.ts +2 -2
- package/dist/Ws.d.ts.map +1 -1
- package/dist/Ws.js +18 -20
- package/dist/Ws.js.map +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/docs/http.md +68 -55
- package/docs/wss.md +1 -1
- package/package.json +3 -5
- package/dist/Browser.d.ts +0 -75
- package/dist/Browser.d.ts.map +0 -1
- package/dist/Browser.js +0 -295
- package/dist/Browser.js.map +0 -1
package/dist/Http.js
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
// {§http-lifecycle}. The implementation depends only on SchemeCtx capabilities.
|
|
4
4
|
var _a;
|
|
5
5
|
import { createParser } from "eventsource-parser";
|
|
6
|
-
import { MimetypeClassifier, NetworkAddress,
|
|
6
|
+
import { MimetypeClassifier, NetworkAddress, ProjectionInputLimitError, Results, } from "@plurnk/plurnk-schemes";
|
|
7
7
|
import { readFile } from "node:fs/promises";
|
|
8
|
-
import Browser, { BROWSER_UA, requireNumEnv } from "./Browser.js";
|
|
9
8
|
import ErrorDetail from "./ErrorDetail.js";
|
|
10
|
-
import WebFetcher, { CACHE_VARIANT_HEADER, PROJECTION_ID_HEADER, cacheVariantEvidence, classifyCacheVariant,
|
|
9
|
+
import WebFetcher, { DEFAULT_WEB_UA, CACHE_VARIANT_HEADER, MATERIALIZER_ID_HEADER, PROJECTION_ID_HEADER, TAVILY_CREDITS_HEADER, TAVILY_REQUEST_ID_HEADER, cacheVariantEvidence, classifyCacheVariant, WebMaterializationError, } from "./WebFetcher.js";
|
|
11
10
|
import { responseMimetype } from "./ContentType.js";
|
|
11
|
+
import { requireNonNegativeIntegerEnv as requireNumEnv } from "./Config.js";
|
|
12
12
|
// The channel the response body streams into, and the header metadata channel.
|
|
13
13
|
const BODY = "body";
|
|
14
14
|
const HEADER = "header";
|
|
@@ -156,74 +156,79 @@ class Http {
|
|
|
156
156
|
requiresWeb: true, // excluded under the loop's noWeb flag
|
|
157
157
|
},
|
|
158
158
|
};
|
|
159
|
-
// The render foundation (lazy chromium). Injectable for tests; one warm
|
|
160
|
-
// pool per Http instance, shared across this scheme's fetches.
|
|
161
|
-
#browser;
|
|
162
159
|
#errorDetailLimit;
|
|
163
160
|
#webFetcher;
|
|
164
|
-
constructor(
|
|
165
|
-
this.#browser = browser;
|
|
161
|
+
constructor() {
|
|
166
162
|
this.#errorDetailLimit = ErrorDetail.configuredLimit();
|
|
167
|
-
this.#webFetcher = new WebFetcher(
|
|
163
|
+
this.#webFetcher = new WebFetcher();
|
|
168
164
|
}
|
|
169
165
|
async ready() {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
166
|
+
WebFetcher.validateConfiguration();
|
|
167
|
+
}
|
|
168
|
+
// Representation preparation is scope-blind. Core applies the authored READ
|
|
169
|
+
// coordinates only after this hook has either materialized a finite GET or
|
|
170
|
+
// returned 102 for a retained SSE response.
|
|
171
|
+
async prepareRepresentation(request, ctx) {
|
|
172
|
+
if (request.target.kind !== "url") {
|
|
173
|
+
return _a.#bad(400, "http", "bad-target", "READ requires an http(s):// URL target.", {
|
|
174
|
+
stage: "target-validation",
|
|
175
|
+
recovery: "Provide an http(s):// URL target.",
|
|
176
|
+
retryable: false,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return this.#prepareGet(request.target, request.pathname, ctx);
|
|
180
|
+
}
|
|
181
|
+
async #prepareGet(target, pathname, ctx) {
|
|
185
182
|
const address = _a.#address(target);
|
|
186
183
|
if (!(address instanceof NetworkAddress))
|
|
187
184
|
return address;
|
|
188
|
-
const {
|
|
185
|
+
const { url } = address;
|
|
189
186
|
const requestHeaders = target.headers ?? [];
|
|
190
|
-
|
|
191
|
-
|
|
187
|
+
let cached;
|
|
188
|
+
const conditional = [];
|
|
192
189
|
const prior = await ctx.entries.read(pathname);
|
|
193
|
-
if (prior.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
190
|
+
if (Results.isErrorStatus(prior.status) && prior.status !== 404) {
|
|
191
|
+
return _a.#passthrough(prior);
|
|
192
|
+
}
|
|
193
|
+
const priorBody = prior.entry?.channels[BODY];
|
|
194
|
+
const priorHeader = prior.entry?.channels[HEADER];
|
|
195
|
+
if (prior.entry !== null && _a.#requestMethod(priorHeader?.content ?? "") === undefined) {
|
|
196
|
+
return { status: 200 };
|
|
197
|
+
}
|
|
198
|
+
if (prior.entry !== null && priorBody !== undefined && priorHeader !== undefined) {
|
|
200
199
|
try {
|
|
201
|
-
|
|
200
|
+
if (await _a.#reusableGetRepresentation(prior.entry, requestHeaders, ctx.projection)) {
|
|
201
|
+
cached = prior.entry;
|
|
202
|
+
if (_a.#materializerIdentity(priorHeader.content) === undefined) {
|
|
203
|
+
conditional.push(..._a.#validators(priorHeader.content));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
202
206
|
}
|
|
203
207
|
catch (cause) {
|
|
204
|
-
return _a.#materializationFailure(url, "GET", new WebMaterializationError(
|
|
205
|
-
}
|
|
206
|
-
if (reusable && _a.#fresh(priorHeader)) {
|
|
207
|
-
return { shape: "passthrough", status: 200 };
|
|
208
|
+
return _a.#materializationFailure(url, "GET", new WebMaterializationError(_a.#sourceMimetype(priorHeader.content), cause));
|
|
208
209
|
}
|
|
209
210
|
}
|
|
210
|
-
if (
|
|
211
|
-
return
|
|
211
|
+
if (cached !== undefined && _a.#fresh(cached.channels[HEADER].content)) {
|
|
212
|
+
return { status: 200 };
|
|
212
213
|
}
|
|
213
|
-
// {§prefetch} WebFetcher preserves caller cancellation as rejection;
|
|
214
|
-
// this operation owns its one model-facing 499 projection.
|
|
215
214
|
let fetched;
|
|
216
215
|
try {
|
|
217
216
|
fetched = await this.#webFetcher.fetch(url, {
|
|
218
217
|
signal: ctx.signal,
|
|
219
218
|
headers: requestHeaders,
|
|
219
|
+
...(conditional.length > 0
|
|
220
|
+
? { conditionalHeaders: conditional }
|
|
221
|
+
: {}),
|
|
222
|
+
guarded: false,
|
|
223
|
+
acceptHttpErrors: true,
|
|
224
|
+
preserveUnavailable: true,
|
|
220
225
|
});
|
|
221
226
|
}
|
|
222
|
-
catch (
|
|
223
|
-
if (ctx.signal?.aborted === true &&
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (ctx.signal?.aborted === true && error === ctx.signal.reason) {
|
|
224
229
|
return _a.#cancelled(url, "GET");
|
|
225
230
|
}
|
|
226
|
-
throw
|
|
231
|
+
throw error;
|
|
227
232
|
}
|
|
228
233
|
if (fetched === null) {
|
|
229
234
|
return _a.#bad(404, "http", "not-materialized", `The URL ${url} could not be materialized.`, {
|
|
@@ -232,48 +237,111 @@ class Http {
|
|
|
232
237
|
retryable: true,
|
|
233
238
|
});
|
|
234
239
|
}
|
|
240
|
+
if (fetched.status === 304) {
|
|
241
|
+
if (cached === undefined) {
|
|
242
|
+
return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 without a reusable stored representation.`, {
|
|
243
|
+
target: url,
|
|
244
|
+
method: "GET",
|
|
245
|
+
stage: "acquisition",
|
|
246
|
+
retryable: true,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const cachedHeader = cached.channels[HEADER].content;
|
|
250
|
+
if (_a.#materializerIdentity(cachedHeader) !== undefined) {
|
|
251
|
+
return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 for a stored representation that requires full reacquisition.`, {
|
|
252
|
+
target: url,
|
|
253
|
+
method: "GET",
|
|
254
|
+
stage: "acquisition",
|
|
255
|
+
retryable: true,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const responseHeaders = fetched.responseHeaders ?? [];
|
|
259
|
+
if (!_a.#revalidationCorresponds(cachedHeader, new Headers(responseHeaders.map(([name, value]) => [name, value])))) {
|
|
260
|
+
return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 without identifying the stored representation nominated for revalidation.`, {
|
|
261
|
+
target: url,
|
|
262
|
+
method: "GET",
|
|
263
|
+
stage: "acquisition",
|
|
264
|
+
retryable: true,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const channels = {
|
|
268
|
+
...cached.channels,
|
|
269
|
+
[HEADER]: {
|
|
270
|
+
...cached.channels[HEADER],
|
|
271
|
+
content: _a.#refreshAfter304(cachedHeader, responseHeaders, requestHeaders),
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
const written = await ctx.entries.write(pathname, {
|
|
275
|
+
channels,
|
|
276
|
+
tags: cached.tags,
|
|
277
|
+
});
|
|
278
|
+
if (Results.isErrorStatus(written.status))
|
|
279
|
+
return _a.#passthrough(written);
|
|
280
|
+
return { status: 200 };
|
|
281
|
+
}
|
|
282
|
+
if (fetched.mimetype === "text/event-stream"
|
|
283
|
+
&& fetched.response?.body !== null
|
|
284
|
+
&& fetched.response?.body !== undefined) {
|
|
285
|
+
return this.#openEventStream(address, fetched, ctx);
|
|
286
|
+
}
|
|
235
287
|
let materialized;
|
|
236
288
|
try {
|
|
237
|
-
materialized = await WebFetcher.materialize(fetched, ctx.projection);
|
|
289
|
+
materialized = await WebFetcher.materialize(fetched, ctx.projection, ctx.signal);
|
|
238
290
|
}
|
|
239
|
-
catch (
|
|
291
|
+
catch (error) {
|
|
240
292
|
if (ctx.signal?.aborted === true)
|
|
241
293
|
return _a.#cancelled(url, "GET");
|
|
242
|
-
if (
|
|
243
|
-
return _a.#materializationFailure(url, "GET",
|
|
294
|
+
if (error instanceof WebMaterializationError) {
|
|
295
|
+
return _a.#materializationFailure(url, "GET", error);
|
|
244
296
|
}
|
|
245
|
-
throw
|
|
246
|
-
}
|
|
247
|
-
if (materialized === null)
|
|
248
|
-
return _a.#
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
: { [HEADER]: { content: materialized.header, mimetype: "text/plain" } }),
|
|
255
|
-
};
|
|
256
|
-
const written = await ctx.entries.write(pathname, { channels, tags: [] });
|
|
257
|
-
return _a.#passthrough(written);
|
|
258
|
-
}
|
|
259
|
-
// An unscoped READ fetches/revalidates. A scoped READ observes its selected
|
|
260
|
-
// already-materialized channel through universal READ: refetching would
|
|
261
|
-
// discard the requested range and return another asynchronous whole-body 102.
|
|
262
|
-
async read(statement, ctx) {
|
|
263
|
-
if (statement.target === null || statement.target.kind !== "url") {
|
|
264
|
-
return _a.#bad(400, "http", "bad-target", "READ requires an http(s):// URL target.", {
|
|
265
|
-
stage: "target-validation",
|
|
266
|
-
recovery: "Provide an http(s):// URL target.",
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
if (materialized === null) {
|
|
300
|
+
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.`, {
|
|
301
|
+
target: url,
|
|
302
|
+
method: "GET",
|
|
303
|
+
mimetype: fetched.mimetype,
|
|
304
|
+
stage: "materialization",
|
|
305
|
+
recovery: "Inspect #header or use a byte-capable client.",
|
|
267
306
|
retryable: false,
|
|
268
307
|
});
|
|
269
308
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
309
|
+
const written = await ctx.entries.write(pathname, {
|
|
310
|
+
channels: WebFetcher.materializedChannels(materialized, { url, method: "GET" }),
|
|
311
|
+
tags: [],
|
|
312
|
+
});
|
|
313
|
+
if (Results.isErrorStatus(written.status))
|
|
314
|
+
return _a.#passthrough(written);
|
|
315
|
+
return { status: 200 };
|
|
316
|
+
}
|
|
317
|
+
async #openEventStream(address, fetched, ctx) {
|
|
318
|
+
const response = fetched.response;
|
|
319
|
+
if (response?.body === null || response?.body === undefined) {
|
|
320
|
+
throw new Error("SSE preparation lost its response body.");
|
|
275
321
|
}
|
|
276
|
-
|
|
322
|
+
const local = new AbortController();
|
|
323
|
+
const handle = {
|
|
324
|
+
cancel: async () => {
|
|
325
|
+
local.abort();
|
|
326
|
+
await response.body?.cancel().catch(() => { });
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
const written = await ctx.entries.write(address.pathname, _a.#seedEntry());
|
|
330
|
+
if (Results.isErrorStatus(written.status))
|
|
331
|
+
return _a.#passthrough(written);
|
|
332
|
+
const subscription = await ctx.subscriptions.open(address.pathname, handle);
|
|
333
|
+
if (fetched.header !== undefined) {
|
|
334
|
+
await subscription.notifyChunk(HEADER, fetched.header, "text/plain");
|
|
335
|
+
}
|
|
336
|
+
void _a.#settleEventStream(subscription, response, {
|
|
337
|
+
url: address.url,
|
|
338
|
+
method: "GET",
|
|
339
|
+
signal: local.signal,
|
|
340
|
+
errorDetailLimit: this.#errorDetailLimit,
|
|
341
|
+
}).catch((error) => {
|
|
342
|
+
console.error("HTTP SSE terminal cleanup failed", { url: address.url, error });
|
|
343
|
+
});
|
|
344
|
+
return { shape: "passthrough", status: 102 };
|
|
277
345
|
}
|
|
278
346
|
// EDIT -> PUT the body (full-resource replace). `<L>` has no meaning against a
|
|
279
347
|
// remote resource - reject rather than silently ignore the model's intent.
|
|
@@ -301,7 +369,7 @@ class Http {
|
|
|
301
369
|
retryable: false,
|
|
302
370
|
});
|
|
303
371
|
}
|
|
304
|
-
return this.#
|
|
372
|
+
return this.#request(statement.target, ctx, "PUT", statement.body ?? "");
|
|
305
373
|
}
|
|
306
374
|
async edit(statement, ctx) {
|
|
307
375
|
return this.editBatch([statement], ctx);
|
|
@@ -316,7 +384,7 @@ class Http {
|
|
|
316
384
|
retryable: false,
|
|
317
385
|
});
|
|
318
386
|
}
|
|
319
|
-
return this.#
|
|
387
|
+
return this.#request(statement.target, ctx, "DELETE", statement.body ?? undefined);
|
|
320
388
|
}
|
|
321
389
|
// SEND dispatch — status-code-as-verb (SPEC {§op-surface}).
|
|
322
390
|
// 200 -> request with body (POST), stream response
|
|
@@ -335,7 +403,7 @@ class Http {
|
|
|
335
403
|
const status = statement.signal;
|
|
336
404
|
if (status === 200) {
|
|
337
405
|
const body = statement.body?.raw ?? "";
|
|
338
|
-
return this.#
|
|
406
|
+
return this.#request(statement.target, ctx, "POST", body);
|
|
339
407
|
}
|
|
340
408
|
if (status === 410) {
|
|
341
409
|
const address = _a.#address(statement.target);
|
|
@@ -345,7 +413,7 @@ class Http {
|
|
|
345
413
|
}
|
|
346
414
|
if (status === 499) {
|
|
347
415
|
// Cancellation is routed by the engine to the subscription's
|
|
348
|
-
// SubscriptionHandle.cancel (registered
|
|
416
|
+
// SubscriptionHandle.cancel (registered by #request). Nothing
|
|
349
417
|
// for the scheme to do at the op level.
|
|
350
418
|
return { shape: "passthrough", status: 200 };
|
|
351
419
|
}
|
|
@@ -356,13 +424,14 @@ class Http {
|
|
|
356
424
|
retryable: false,
|
|
357
425
|
});
|
|
358
426
|
}
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
427
|
+
// Mutation responses use the same entry/channel and subscription primitives
|
|
428
|
+
// as every live producer; GET acquisition belongs solely to
|
|
429
|
+
// prepareRepresentation.
|
|
430
|
+
async #request(target, ctx, method, body) {
|
|
362
431
|
const address = _a.#address(target);
|
|
363
432
|
if (!(address instanceof NetworkAddress))
|
|
364
433
|
return address;
|
|
365
|
-
const url =
|
|
434
|
+
const { url } = address;
|
|
366
435
|
const { pathname } = address;
|
|
367
436
|
const headers = target.headers ?? [];
|
|
368
437
|
const publishedChannel = target.fragment ?? _a.manifest.defaultChannel;
|
|
@@ -375,48 +444,6 @@ class Http {
|
|
|
375
444
|
retryable: false,
|
|
376
445
|
});
|
|
377
446
|
}
|
|
378
|
-
// {§revalidation} — recover only a GET-marked representation and its
|
|
379
|
-
// validators before the seed write replaces the stored channels.
|
|
380
|
-
let cached;
|
|
381
|
-
const conditional = [];
|
|
382
|
-
if (method === "GET") {
|
|
383
|
-
const prior = await ctx.entries.read(pathname);
|
|
384
|
-
if (Results.isErrorStatus(prior.status) && prior.status !== 404) {
|
|
385
|
-
return _a.#passthrough(prior);
|
|
386
|
-
}
|
|
387
|
-
const pb = prior.entry?.channels[BODY];
|
|
388
|
-
const ph = prior.entry?.channels[HEADER];
|
|
389
|
-
if (prior.entry !== null && pb !== undefined && ph !== undefined) {
|
|
390
|
-
try {
|
|
391
|
-
if (await _a.#reusableGetRepresentation(prior.entry, headers, ctx.projection)) {
|
|
392
|
-
const html = prior.entry.channels.html;
|
|
393
|
-
cached = {
|
|
394
|
-
header: ph.content,
|
|
395
|
-
body: { content: pb.content, mimetype: pb.mimetype },
|
|
396
|
-
...(html === undefined || html.content.length === 0 ? {} : { html: { content: html.content, mimetype: html.mimetype } }),
|
|
397
|
-
};
|
|
398
|
-
conditional.push(..._a.#validators(ph.content));
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
catch (cause) {
|
|
402
|
-
return _a.#materializationFailure(url, method, new WebMaterializationError("projection", _a.#sourceMimetype(ph.content), cause));
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
// {§revalidation} TTL fast path. Serving does not refresh the stamp;
|
|
407
|
-
// only a network acquisition or 304 vouches for a new acquisition time.
|
|
408
|
-
if (cached !== undefined && _a.#fresh(cached.header)) {
|
|
409
|
-
const written = await ctx.entries.write(pathname, _a.#seedEntry());
|
|
410
|
-
if (Results.isErrorStatus(written.status))
|
|
411
|
-
return _a.#passthrough(written);
|
|
412
|
-
const subscription = await ctx.subscriptions.open(pathname, { cancel: () => { } }, { publishedChannel });
|
|
413
|
-
await subscription.notifyChunk(HEADER, cached.header, "text/plain");
|
|
414
|
-
if (cached.html !== undefined)
|
|
415
|
-
await subscription.notifyChunk("html", cached.html.content, cached.html.mimetype);
|
|
416
|
-
await subscription.notifyChunk(BODY, cached.body.content, cached.body.mimetype);
|
|
417
|
-
await subscription.close({ status: 200 }, `ttl-fresh; ${cached.body.content.length} chars from cache`);
|
|
418
|
-
return { shape: "passthrough", status: 102 };
|
|
419
|
-
}
|
|
420
447
|
// Local AbortController for force-cancel from outside (SEND[499]).
|
|
421
448
|
const local = new AbortController();
|
|
422
449
|
const handle = { cancel: () => local.abort() };
|
|
@@ -426,94 +453,21 @@ class Http {
|
|
|
426
453
|
if (Results.isErrorStatus(written.status))
|
|
427
454
|
return _a.#passthrough(written);
|
|
428
455
|
// open() returns the worker+teardown-composed signal — fires on loop.cancel
|
|
429
|
-
// OR our local teardown. Wire it so either path aborts
|
|
430
|
-
const subscription = await ctx.subscriptions.open(pathname, handle
|
|
456
|
+
// OR our local teardown. Wire it so either path aborts acquisition.
|
|
457
|
+
const subscription = await ctx.subscriptions.open(pathname, handle);
|
|
431
458
|
const onAbort = () => local.abort();
|
|
432
459
|
subscription.addEventListener("abort", onAbort, { once: true });
|
|
433
|
-
let detached = false;
|
|
434
460
|
try {
|
|
435
461
|
const response = await fetch(url, {
|
|
436
462
|
method,
|
|
437
463
|
body,
|
|
438
|
-
// One browser identity on the wire (never Node's default "node"
|
|
439
|
-
// UA — a loud automated-client fingerprint). The model's own
|
|
440
|
-
// {User-Agent: …} block wins when present.
|
|
441
464
|
headers: headers.some(([k]) => k.toLowerCase() === "user-agent")
|
|
442
|
-
?
|
|
443
|
-
: [["User-Agent",
|
|
465
|
+
? headers
|
|
466
|
+
: [["User-Agent", DEFAULT_WEB_UA], ...headers],
|
|
444
467
|
signal: local.signal,
|
|
445
468
|
redirect: "follow",
|
|
446
469
|
});
|
|
447
|
-
// {§revalidation} A corresponding 304 restores the nominated GET
|
|
448
|
-
// representation into the freshly seeded channels and remains an
|
|
449
|
-
// ordinary streaming READ.
|
|
450
|
-
if (cached !== undefined && response.status === 304) {
|
|
451
|
-
if (!_a.#revalidationCorresponds(cached.header, response.headers)) {
|
|
452
|
-
throw new Error("HTTP 304 did not identify the stored representation nominated for revalidation.");
|
|
453
|
-
}
|
|
454
|
-
await subscription.notifyChunk(HEADER, _a.#refreshAfter304(cached.header, [...response.headers], headers), "text/plain");
|
|
455
|
-
if (cached.html !== undefined)
|
|
456
|
-
await subscription.notifyChunk("html", cached.html.content, cached.html.mimetype);
|
|
457
|
-
await subscription.notifyChunk(BODY, cached.body.content, cached.body.mimetype);
|
|
458
|
-
await subscription.close({ status: 200 }, `revalidated 304; ${cached.body.content.length} chars from cache`);
|
|
459
|
-
return { shape: "passthrough", status: 102 };
|
|
460
|
-
}
|
|
461
470
|
const responseMime = responseMimetype(response.headers.get("content-type"));
|
|
462
|
-
// Always-render: a GET of an HTML page is re-acquired through the
|
|
463
|
-
// browser so the body is projected from the final rendered DOM. The probe-fetch
|
|
464
|
-
// body is discarded — the browser does its own navigation. Only GET
|
|
465
|
-
// renders; POST/PUT/DELETE can't be replayed as a browser navigation.
|
|
466
|
-
const isHtml = method === "GET" && MimetypeClassifier.isHtml(responseMime);
|
|
467
|
-
if (isHtml) {
|
|
468
|
-
await response.body?.cancel();
|
|
469
|
-
let result;
|
|
470
|
-
try {
|
|
471
|
-
result = await this.#browser.render(url, {
|
|
472
|
-
workerId: ctx.workerId,
|
|
473
|
-
signal: local.signal,
|
|
474
|
-
headers,
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
catch (cause) {
|
|
478
|
-
throw new WebMaterializationError("render", responseMime, cause);
|
|
479
|
-
}
|
|
480
|
-
await _a.#writeHeader(subscription, method, result.status, result.statusText, result.headers, headers);
|
|
481
|
-
await subscription.notifyChunk("html", result.html, "text/html");
|
|
482
|
-
const materialized = await WebFetcher.materialize({ body: result.html, mimetype: "text/html" }, ctx.projection);
|
|
483
|
-
if (materialized === null) {
|
|
484
|
-
const failure = _a.#noReadableProjection(url);
|
|
485
|
-
await subscription.close(failure, failure.problem?.detail);
|
|
486
|
-
return failure;
|
|
487
|
-
}
|
|
488
|
-
if (materialized.projection !== undefined) {
|
|
489
|
-
await _a.#writeProjectionIdentity(subscription, materialized.projection.identity);
|
|
490
|
-
}
|
|
491
|
-
await subscription.notifyChunk(BODY, materialized.body.content, materialized.body.mimetype);
|
|
492
|
-
await subscription.close({ status: 200 }, `rendered HTTP ${result.status}; ${materialized.body.content.length} readable chars`);
|
|
493
|
-
return { shape: "passthrough", status: 102 };
|
|
494
|
-
}
|
|
495
|
-
// SSE: an event stream is parsed into its `data` payloads (SPEC {§sse}),
|
|
496
|
-
// one notifyChunk per event with the `data:`/comment framing stripped,
|
|
497
|
-
// so the model reads event content and not the transport. A long-lived
|
|
498
|
-
// GET; events land in the body channel as they arrive across turns,
|
|
499
|
-
// until the origin closes. Only GET — a POST reply is never an SSE READ.
|
|
500
|
-
if (method === "GET" && responseMime === "text/event-stream") {
|
|
501
|
-
await _a.#writeHeader(subscription, method, response.status, response.statusText, [...response.headers], headers);
|
|
502
|
-
if (response.body === null) {
|
|
503
|
-
await subscription.close({ status: 200 }, "SSE stream; empty body");
|
|
504
|
-
return { shape: "passthrough", status: 102 };
|
|
505
|
-
}
|
|
506
|
-
detached = true;
|
|
507
|
-
void _a.#settleEventStream(subscription, response, {
|
|
508
|
-
url,
|
|
509
|
-
method,
|
|
510
|
-
signal: local.signal,
|
|
511
|
-
errorDetailLimit: this.#errorDetailLimit,
|
|
512
|
-
}).catch((error) => {
|
|
513
|
-
console.error("HTTP SSE terminal cleanup failed", { url, error });
|
|
514
|
-
}).finally(() => subscription.removeEventListener("abort", onAbort));
|
|
515
|
-
return { shape: "passthrough", status: 102 };
|
|
516
|
-
}
|
|
517
471
|
// {§http-lifecycle}/{§mimetype-classifier} String channels retain
|
|
518
472
|
// textual response data. Binary input is transient: an installed
|
|
519
473
|
// reader may derive Unicode, otherwise the durable body is a typed
|
|
@@ -599,8 +553,7 @@ class Http {
|
|
|
599
553
|
return result;
|
|
600
554
|
}
|
|
601
555
|
finally {
|
|
602
|
-
|
|
603
|
-
subscription.removeEventListener("abort", onAbort);
|
|
556
|
+
subscription.removeEventListener("abort", onAbort);
|
|
604
557
|
}
|
|
605
558
|
}
|
|
606
559
|
// {§http-manifest}/{§http-lifecycle} Seed the declared channel shape before
|
|
@@ -613,11 +566,16 @@ class Http {
|
|
|
613
566
|
}]));
|
|
614
567
|
return { channels, tags: [] };
|
|
615
568
|
}
|
|
616
|
-
// {§revalidation}
|
|
617
|
-
//
|
|
618
|
-
// representation, regardless of whether partial content is non-empty.
|
|
569
|
+
// {§revalidation} The body and header must have settled successfully. An
|
|
570
|
+
// auxiliary channel may have terminally failed without invalidating body.
|
|
619
571
|
static #representationComplete(entry) {
|
|
620
|
-
|
|
572
|
+
const bodyState = entry.channels[BODY]?.state;
|
|
573
|
+
const headerState = entry.channels[HEADER]?.state;
|
|
574
|
+
const successful = (state) => state === "static" || state === "closed";
|
|
575
|
+
const terminal = (state) => successful(state) || state === "errored";
|
|
576
|
+
return successful(bodyState)
|
|
577
|
+
&& successful(headerState)
|
|
578
|
+
&& Object.values(entry.channels).every(({ state }) => terminal(state));
|
|
621
579
|
}
|
|
622
580
|
// {§revalidation} Direct READ and exact FIND share the complete reusable
|
|
623
581
|
// representation predicate; freshness only decides how READ uses a match.
|
|
@@ -636,13 +594,16 @@ class Http {
|
|
|
636
594
|
// {§revalidation} Origin headers come first; authoritative package method
|
|
637
595
|
// and acquisition metadata are appended last.
|
|
638
596
|
static async #writeHeader(subscription, method, status, statusText, responseHeaders, requestHeaders) {
|
|
597
|
+
await subscription.notifyChunk(HEADER, _a.#responseHeader(method, status, statusText, responseHeaders, requestHeaders), "text/plain");
|
|
598
|
+
}
|
|
599
|
+
static #responseHeader(method, status, statusText, responseHeaders, requestHeaders) {
|
|
639
600
|
const lines = [`HTTP ${status} ${statusText}`];
|
|
640
601
|
for (const [k, v] of responseHeaders)
|
|
641
602
|
lines.push(`${k}: ${v}`);
|
|
642
603
|
lines.push(`${REQUEST_METHOD}: ${method}`);
|
|
643
604
|
lines.push(`${FETCHED_AT}: ${new Date().toISOString()}`);
|
|
644
605
|
lines.push(cacheVariantEvidence(classifyCacheVariant(requestHeaders, responseHeaders)));
|
|
645
|
-
|
|
606
|
+
return lines.join("\n");
|
|
646
607
|
}
|
|
647
608
|
static async #writeProjectionIdentity(subscription, identity) {
|
|
648
609
|
await subscription.notifyChunk(HEADER, `\n${WebFetcher.projectionEvidence(identity)}`, "text/plain");
|
|
@@ -652,6 +613,11 @@ class Http {
|
|
|
652
613
|
static async #settleEventStream(subscription, response, options) {
|
|
653
614
|
try {
|
|
654
615
|
const events = await _a.#streamEvents(subscription, response);
|
|
616
|
+
if (options.signal.aborted) {
|
|
617
|
+
const result = _a.#cancelled(options.url, options.method);
|
|
618
|
+
await subscription.close(result, result.problem?.detail);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
655
621
|
await subscription.close({ status: 200 }, `SSE stream; ${events} events`);
|
|
656
622
|
}
|
|
657
623
|
catch (error) {
|
|
@@ -873,6 +839,9 @@ class Http {
|
|
|
873
839
|
static #projectionIdentity(priorHeader) {
|
|
874
840
|
return _a.#packageHeaderValue(priorHeader, PROJECTION_ID_HEADER);
|
|
875
841
|
}
|
|
842
|
+
static #materializerIdentity(priorHeader) {
|
|
843
|
+
return _a.#packageHeaderValue(priorHeader, MATERIALIZER_ID_HEADER);
|
|
844
|
+
}
|
|
876
845
|
static #cacheVariant(priorHeader) {
|
|
877
846
|
const value = _a.#packageHeaderValue(priorHeader, CACHE_VARIANT_HEADER);
|
|
878
847
|
return value === "default" || value === "bypass" ? value : undefined;
|
|
@@ -881,10 +850,22 @@ class Http {
|
|
|
881
850
|
return responseMimetype(lastHeaderValue(header, "content-type") ?? null);
|
|
882
851
|
}
|
|
883
852
|
static async #projectionCurrent(header, projection) {
|
|
853
|
+
const sourceMimetype = _a.#sourceMimetype(header);
|
|
854
|
+
const isHtml = MimetypeClassifier.isHtml(sourceMimetype);
|
|
855
|
+
const materializerIdentity = _a.#materializerIdentity(header);
|
|
856
|
+
if (materializerIdentity !== undefined) {
|
|
857
|
+
if (!WebFetcher.materializerCurrent(materializerIdentity))
|
|
858
|
+
return false;
|
|
859
|
+
if (materializerIdentity.startsWith("tavily-extract:")
|
|
860
|
+
|| materializerIdentity === "origin-markdown:v1")
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
863
|
+
if (isHtml && materializerIdentity === undefined)
|
|
864
|
+
return false;
|
|
884
865
|
const storedIdentity = _a.#projectionIdentity(header);
|
|
885
866
|
if (storedIdentity === undefined)
|
|
886
|
-
return
|
|
887
|
-
return await projection.identity(
|
|
867
|
+
return !isHtml;
|
|
868
|
+
return await projection.identity(sourceMimetype) === storedIdentity;
|
|
888
869
|
}
|
|
889
870
|
// RFC 9111 §3.2 permits a processed cache to retain the metadata that its
|
|
890
871
|
// stored body depends upon. Other 304 fields update by name; package-owned
|
|
@@ -899,7 +880,15 @@ class Http {
|
|
|
899
880
|
...retained.map(({ name, value }) => [name, value]),
|
|
900
881
|
...updates,
|
|
901
882
|
];
|
|
902
|
-
const
|
|
883
|
+
const packageEvidence = [
|
|
884
|
+
MATERIALIZER_ID_HEADER,
|
|
885
|
+
PROJECTION_ID_HEADER,
|
|
886
|
+
TAVILY_REQUEST_ID_HEADER,
|
|
887
|
+
TAVILY_CREDITS_HEADER,
|
|
888
|
+
].flatMap((field) => {
|
|
889
|
+
const value = _a.#packageHeaderValue(header, field);
|
|
890
|
+
return value === undefined ? [] : [`${field}: ${value}`];
|
|
891
|
+
});
|
|
903
892
|
return [
|
|
904
893
|
lines[0] ?? "HTTP 200 OK",
|
|
905
894
|
...retained.map(({ line }) => line),
|
|
@@ -907,9 +896,7 @@ class Http {
|
|
|
907
896
|
`${REQUEST_METHOD}: GET`,
|
|
908
897
|
`${FETCHED_AT}: ${new Date().toISOString()}`,
|
|
909
898
|
cacheVariantEvidence(classifyCacheVariant(requestHeaders, merged)),
|
|
910
|
-
...
|
|
911
|
-
? []
|
|
912
|
-
: [WebFetcher.projectionEvidence(projectionIdentity)]),
|
|
899
|
+
...packageEvidence,
|
|
913
900
|
].join("\n");
|
|
914
901
|
}
|
|
915
902
|
static #revalidationCorresponds(priorHeader, responseHeaders) {
|
|
@@ -979,20 +966,10 @@ class Http {
|
|
|
979
966
|
});
|
|
980
967
|
}
|
|
981
968
|
console.error("HTTP materialization failed", { method, url, error });
|
|
982
|
-
|
|
983
|
-
return _a.#bad(projection ? 500 : 502, "http", projection ? "projection-failed" : "render-failed", projection
|
|
984
|
-
? `HTTP ${method} ${url} acquired content, but its readable projection failed.`
|
|
985
|
-
: `HTTP ${method} ${url} could not render HTML.`, {
|
|
969
|
+
return _a.#bad(500, "http", "projection-failed", `HTTP ${method} ${url} acquired content, but its readable projection failed.`, {
|
|
986
970
|
target: url,
|
|
987
971
|
method,
|
|
988
972
|
mimetype: error.mimetype,
|
|
989
|
-
stage: error.stage,
|
|
990
|
-
retryable: !projection,
|
|
991
|
-
});
|
|
992
|
-
}
|
|
993
|
-
static #noReadableProjection(url) {
|
|
994
|
-
return _a.#bad(422, "http", "no-readable-projection", `The URL ${url} has no readable projection.`, {
|
|
995
|
-
target: url,
|
|
996
973
|
stage: "projection",
|
|
997
974
|
retryable: false,
|
|
998
975
|
});
|