@plurnk/plurnk-schemes-http 1.4.0 → 1.6.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 +38 -34
- package/SPEC.md +260 -227
- 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 +5 -18
- package/dist/Http.d.ts.map +1 -1
- package/dist/Http.js +216 -233
- 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 +3 -2
- package/dist/Ws.d.ts.map +1 -1
- package/dist/Ws.js +132 -94
- 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 +74 -59
- package/docs/wss.md +19 -14
- 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";
|
|
@@ -150,80 +150,93 @@ class Http {
|
|
|
150
150
|
volatile: true, // remote content can change between fetches
|
|
151
151
|
modelVisible: true,
|
|
152
152
|
glyph: "🌐",
|
|
153
|
-
example:
|
|
153
|
+
example: [
|
|
154
|
+
"## READ0 (https://example.com/page)",
|
|
155
|
+
"",
|
|
156
|
+
"## EDIT0 (https://api.example.com/v1/pets/42{Content-Type: application/json})",
|
|
157
|
+
'{"name":"Mango","status":"available"}',
|
|
158
|
+
"",
|
|
159
|
+
"## SEND0 [200] (https://api.example.com/v1/pets{Content-Type: application/json})",
|
|
160
|
+
'{"name":"Mango","status":"available"}',
|
|
161
|
+
].join("\n"),
|
|
154
162
|
documentation,
|
|
155
163
|
flags: {
|
|
156
164
|
requiresWeb: true, // excluded under the loop's noWeb flag
|
|
157
165
|
},
|
|
158
166
|
};
|
|
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
167
|
#errorDetailLimit;
|
|
163
168
|
#webFetcher;
|
|
164
|
-
constructor(
|
|
165
|
-
this.#browser = browser;
|
|
169
|
+
constructor() {
|
|
166
170
|
this.#errorDetailLimit = ErrorDetail.configuredLimit();
|
|
167
|
-
this.#webFetcher = new WebFetcher(
|
|
171
|
+
this.#webFetcher = new WebFetcher();
|
|
168
172
|
}
|
|
169
173
|
async ready() {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
174
|
+
WebFetcher.validateConfiguration();
|
|
175
|
+
}
|
|
176
|
+
// Representation preparation is scope-blind. Core applies the authored READ
|
|
177
|
+
// coordinates only after this hook has either materialized a finite GET or
|
|
178
|
+
// returned 102 for a retained SSE response.
|
|
179
|
+
async prepareRepresentation(request, ctx) {
|
|
180
|
+
if (request.target.kind !== "url") {
|
|
181
|
+
return _a.#bad(400, "http", "bad-target", "READ requires an http(s):// URL target.", {
|
|
182
|
+
stage: "target-validation",
|
|
183
|
+
recovery: "Provide an http(s):// URL target.",
|
|
184
|
+
retryable: false,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return this.#prepareGet(request.target, request.pathname, ctx);
|
|
188
|
+
}
|
|
189
|
+
async #prepareGet(target, pathname, ctx) {
|
|
185
190
|
const address = _a.#address(target);
|
|
186
191
|
if (!(address instanceof NetworkAddress))
|
|
187
192
|
return address;
|
|
188
|
-
const {
|
|
193
|
+
const { url } = address;
|
|
189
194
|
const requestHeaders = target.headers ?? [];
|
|
190
|
-
|
|
191
|
-
|
|
195
|
+
let cached;
|
|
196
|
+
const conditional = [];
|
|
192
197
|
const prior = await ctx.entries.read(pathname);
|
|
193
|
-
if (prior.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
if (Results.isErrorStatus(prior.status) && prior.status !== 404) {
|
|
199
|
+
return _a.#passthrough(prior);
|
|
200
|
+
}
|
|
201
|
+
const priorBody = prior.entry?.channels[BODY];
|
|
202
|
+
const priorHeader = prior.entry?.channels[HEADER];
|
|
203
|
+
if (prior.entry !== null && _a.#requestMethod(priorHeader?.content ?? "") === undefined) {
|
|
204
|
+
return { status: 200 };
|
|
205
|
+
}
|
|
206
|
+
if (prior.entry !== null && priorBody !== undefined && priorHeader !== undefined) {
|
|
200
207
|
try {
|
|
201
|
-
|
|
208
|
+
if (await _a.#reusableGetRepresentation(prior.entry, requestHeaders, ctx.projection)) {
|
|
209
|
+
cached = prior.entry;
|
|
210
|
+
if (_a.#materializerIdentity(priorHeader.content) === undefined) {
|
|
211
|
+
conditional.push(..._a.#validators(priorHeader.content));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
202
214
|
}
|
|
203
215
|
catch (cause) {
|
|
204
|
-
return _a.#materializationFailure(url, "GET", new WebMaterializationError(
|
|
205
|
-
}
|
|
206
|
-
if (reusable && _a.#fresh(priorHeader)) {
|
|
207
|
-
return { shape: "passthrough", status: 200 };
|
|
216
|
+
return _a.#materializationFailure(url, "GET", new WebMaterializationError(_a.#sourceMimetype(priorHeader.content), cause));
|
|
208
217
|
}
|
|
209
218
|
}
|
|
210
|
-
if (
|
|
211
|
-
return
|
|
219
|
+
if (cached !== undefined && _a.#fresh(cached.channels[HEADER].content)) {
|
|
220
|
+
return { status: 200 };
|
|
212
221
|
}
|
|
213
|
-
// {§prefetch} WebFetcher preserves caller cancellation as rejection;
|
|
214
|
-
// this operation owns its one model-facing 499 projection.
|
|
215
222
|
let fetched;
|
|
216
223
|
try {
|
|
217
224
|
fetched = await this.#webFetcher.fetch(url, {
|
|
218
225
|
signal: ctx.signal,
|
|
219
226
|
headers: requestHeaders,
|
|
227
|
+
...(conditional.length > 0
|
|
228
|
+
? { conditionalHeaders: conditional }
|
|
229
|
+
: {}),
|
|
230
|
+
guarded: false,
|
|
231
|
+
acceptHttpErrors: true,
|
|
232
|
+
preserveUnavailable: true,
|
|
220
233
|
});
|
|
221
234
|
}
|
|
222
|
-
catch (
|
|
223
|
-
if (ctx.signal?.aborted === true &&
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (ctx.signal?.aborted === true && error === ctx.signal.reason) {
|
|
224
237
|
return _a.#cancelled(url, "GET");
|
|
225
238
|
}
|
|
226
|
-
throw
|
|
239
|
+
throw error;
|
|
227
240
|
}
|
|
228
241
|
if (fetched === null) {
|
|
229
242
|
return _a.#bad(404, "http", "not-materialized", `The URL ${url} could not be materialized.`, {
|
|
@@ -232,48 +245,109 @@ class Http {
|
|
|
232
245
|
retryable: true,
|
|
233
246
|
});
|
|
234
247
|
}
|
|
248
|
+
if (fetched.status === 304) {
|
|
249
|
+
if (cached === undefined) {
|
|
250
|
+
return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 without a reusable stored representation.`, {
|
|
251
|
+
target: url,
|
|
252
|
+
method: "GET",
|
|
253
|
+
stage: "acquisition",
|
|
254
|
+
retryable: true,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const cachedHeader = cached.channels[HEADER].content;
|
|
258
|
+
if (_a.#materializerIdentity(cachedHeader) !== undefined) {
|
|
259
|
+
return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 for a stored representation that requires full reacquisition.`, {
|
|
260
|
+
target: url,
|
|
261
|
+
method: "GET",
|
|
262
|
+
stage: "acquisition",
|
|
263
|
+
retryable: true,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
const responseHeaders = fetched.responseHeaders ?? [];
|
|
267
|
+
if (!_a.#revalidationCorresponds(cachedHeader, new Headers(responseHeaders.map(([name, value]) => [name, value])))) {
|
|
268
|
+
return _a.#bad(502, "http", "fetch-failed", `HTTP GET ${url} returned 304 without identifying the stored representation nominated for revalidation.`, {
|
|
269
|
+
target: url,
|
|
270
|
+
method: "GET",
|
|
271
|
+
stage: "acquisition",
|
|
272
|
+
retryable: true,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
const channels = {
|
|
276
|
+
...cached.channels,
|
|
277
|
+
[HEADER]: {
|
|
278
|
+
...cached.channels[HEADER],
|
|
279
|
+
content: _a.#refreshAfter304(cachedHeader, responseHeaders, requestHeaders),
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
const written = await ctx.entries.write(pathname, {
|
|
283
|
+
channels,
|
|
284
|
+
});
|
|
285
|
+
if (Results.isErrorStatus(written.status))
|
|
286
|
+
return _a.#passthrough(written);
|
|
287
|
+
return { status: 200 };
|
|
288
|
+
}
|
|
289
|
+
if (fetched.mimetype === "text/event-stream"
|
|
290
|
+
&& fetched.response?.body !== null
|
|
291
|
+
&& fetched.response?.body !== undefined) {
|
|
292
|
+
return this.#openEventStream(address, fetched, ctx);
|
|
293
|
+
}
|
|
235
294
|
let materialized;
|
|
236
295
|
try {
|
|
237
|
-
materialized = await WebFetcher.materialize(fetched, ctx.projection);
|
|
296
|
+
materialized = await WebFetcher.materialize(fetched, ctx.projection, ctx.signal);
|
|
238
297
|
}
|
|
239
|
-
catch (
|
|
298
|
+
catch (error) {
|
|
240
299
|
if (ctx.signal?.aborted === true)
|
|
241
300
|
return _a.#cancelled(url, "GET");
|
|
242
|
-
if (
|
|
243
|
-
return _a.#materializationFailure(url, "GET",
|
|
301
|
+
if (error instanceof WebMaterializationError) {
|
|
302
|
+
return _a.#materializationFailure(url, "GET", error);
|
|
244
303
|
}
|
|
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.",
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
if (materialized === null) {
|
|
307
|
+
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.`, {
|
|
308
|
+
target: url,
|
|
309
|
+
method: "GET",
|
|
310
|
+
mimetype: fetched.mimetype,
|
|
311
|
+
stage: "materialization",
|
|
312
|
+
recovery: "Inspect #header or use a byte-capable client.",
|
|
267
313
|
retryable: false,
|
|
268
314
|
});
|
|
269
315
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return _a.#passthrough(
|
|
316
|
+
const written = await ctx.entries.write(pathname, {
|
|
317
|
+
channels: WebFetcher.materializedChannels(materialized, { url, method: "GET" }),
|
|
318
|
+
});
|
|
319
|
+
if (Results.isErrorStatus(written.status))
|
|
320
|
+
return _a.#passthrough(written);
|
|
321
|
+
return { status: 200 };
|
|
322
|
+
}
|
|
323
|
+
async #openEventStream(address, fetched, ctx) {
|
|
324
|
+
const response = fetched.response;
|
|
325
|
+
if (response?.body === null || response?.body === undefined) {
|
|
326
|
+
throw new Error("SSE preparation lost its response body.");
|
|
275
327
|
}
|
|
276
|
-
|
|
328
|
+
const local = new AbortController();
|
|
329
|
+
const handle = {
|
|
330
|
+
cancel: async () => {
|
|
331
|
+
local.abort();
|
|
332
|
+
await response.body?.cancel().catch(() => { });
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
const written = await ctx.entries.write(address.pathname, _a.#seedEntry());
|
|
336
|
+
if (Results.isErrorStatus(written.status))
|
|
337
|
+
return _a.#passthrough(written);
|
|
338
|
+
const subscription = await ctx.subscriptions.open(address.pathname, handle);
|
|
339
|
+
if (fetched.header !== undefined) {
|
|
340
|
+
await subscription.notifyChunk(HEADER, fetched.header, "text/plain");
|
|
341
|
+
}
|
|
342
|
+
void _a.#settleEventStream(subscription, response, {
|
|
343
|
+
url: address.url,
|
|
344
|
+
method: "GET",
|
|
345
|
+
signal: local.signal,
|
|
346
|
+
errorDetailLimit: this.#errorDetailLimit,
|
|
347
|
+
}).catch((error) => {
|
|
348
|
+
console.error("HTTP SSE terminal cleanup failed", { url: address.url, error });
|
|
349
|
+
});
|
|
350
|
+
return { shape: "passthrough", status: 102 };
|
|
277
351
|
}
|
|
278
352
|
// EDIT -> PUT the body (full-resource replace). `<L>` has no meaning against a
|
|
279
353
|
// remote resource - reject rather than silently ignore the model's intent.
|
|
@@ -301,12 +375,12 @@ class Http {
|
|
|
301
375
|
retryable: false,
|
|
302
376
|
});
|
|
303
377
|
}
|
|
304
|
-
return this.#
|
|
378
|
+
return this.#request(statement.target, ctx, "PUT", statement.body ?? "");
|
|
305
379
|
}
|
|
306
380
|
async edit(statement, ctx) {
|
|
307
381
|
return this.editBatch([statement], ctx);
|
|
308
382
|
}
|
|
309
|
-
// KILL -> DELETE the resource. Distinct from SEND
|
|
383
|
+
// KILL -> DELETE the resource. Distinct from SEND signal 410 (which drops the local
|
|
310
384
|
// cached entry): KILL is an HTTP DELETE request to the remote.
|
|
311
385
|
async kill(statement, ctx) {
|
|
312
386
|
if (statement.target === null || statement.target.kind !== "url") {
|
|
@@ -316,7 +390,7 @@ class Http {
|
|
|
316
390
|
retryable: false,
|
|
317
391
|
});
|
|
318
392
|
}
|
|
319
|
-
return this.#
|
|
393
|
+
return this.#request(statement.target, ctx, "DELETE", statement.body ?? undefined);
|
|
320
394
|
}
|
|
321
395
|
// SEND dispatch — status-code-as-verb (SPEC {§op-surface}).
|
|
322
396
|
// 200 -> request with body (POST), stream response
|
|
@@ -335,7 +409,7 @@ class Http {
|
|
|
335
409
|
const status = statement.signal;
|
|
336
410
|
if (status === 200) {
|
|
337
411
|
const body = statement.body?.raw ?? "";
|
|
338
|
-
return this.#
|
|
412
|
+
return this.#request(statement.target, ctx, "POST", body);
|
|
339
413
|
}
|
|
340
414
|
if (status === 410) {
|
|
341
415
|
const address = _a.#address(statement.target);
|
|
@@ -345,7 +419,7 @@ class Http {
|
|
|
345
419
|
}
|
|
346
420
|
if (status === 499) {
|
|
347
421
|
// Cancellation is routed by the engine to the subscription's
|
|
348
|
-
// SubscriptionHandle.cancel (registered
|
|
422
|
+
// SubscriptionHandle.cancel (registered by #request). Nothing
|
|
349
423
|
// for the scheme to do at the op level.
|
|
350
424
|
return { shape: "passthrough", status: 200 };
|
|
351
425
|
}
|
|
@@ -356,13 +430,14 @@ class Http {
|
|
|
356
430
|
retryable: false,
|
|
357
431
|
});
|
|
358
432
|
}
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
433
|
+
// Mutation responses use the same entry/channel and subscription primitives
|
|
434
|
+
// as every live producer; GET acquisition belongs solely to
|
|
435
|
+
// prepareRepresentation.
|
|
436
|
+
async #request(target, ctx, method, body) {
|
|
362
437
|
const address = _a.#address(target);
|
|
363
438
|
if (!(address instanceof NetworkAddress))
|
|
364
439
|
return address;
|
|
365
|
-
const url =
|
|
440
|
+
const { url } = address;
|
|
366
441
|
const { pathname } = address;
|
|
367
442
|
const headers = target.headers ?? [];
|
|
368
443
|
const publishedChannel = target.fragment ?? _a.manifest.defaultChannel;
|
|
@@ -375,49 +450,7 @@ class Http {
|
|
|
375
450
|
retryable: false,
|
|
376
451
|
});
|
|
377
452
|
}
|
|
378
|
-
//
|
|
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
|
-
// Local AbortController for force-cancel from outside (SEND[499]).
|
|
453
|
+
// Local AbortController for force-cancel from outside (SEND signal 499).
|
|
421
454
|
const local = new AbortController();
|
|
422
455
|
const handle = { cancel: () => local.abort() };
|
|
423
456
|
// {§http-lifecycle} open() binds an existing entry, so the handler seeds
|
|
@@ -426,94 +459,21 @@ class Http {
|
|
|
426
459
|
if (Results.isErrorStatus(written.status))
|
|
427
460
|
return _a.#passthrough(written);
|
|
428
461
|
// 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
|
|
462
|
+
// OR our local teardown. Wire it so either path aborts acquisition.
|
|
463
|
+
const subscription = await ctx.subscriptions.open(pathname, handle);
|
|
431
464
|
const onAbort = () => local.abort();
|
|
432
465
|
subscription.addEventListener("abort", onAbort, { once: true });
|
|
433
|
-
let detached = false;
|
|
434
466
|
try {
|
|
435
467
|
const response = await fetch(url, {
|
|
436
468
|
method,
|
|
437
469
|
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
470
|
headers: headers.some(([k]) => k.toLowerCase() === "user-agent")
|
|
442
|
-
?
|
|
443
|
-
: [["User-Agent",
|
|
471
|
+
? headers
|
|
472
|
+
: [["User-Agent", DEFAULT_WEB_UA], ...headers],
|
|
444
473
|
signal: local.signal,
|
|
445
474
|
redirect: "follow",
|
|
446
475
|
});
|
|
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
476
|
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
477
|
// {§http-lifecycle}/{§mimetype-classifier} String channels retain
|
|
518
478
|
// textual response data. Binary input is transient: an installed
|
|
519
479
|
// reader may derive Unicode, otherwise the durable body is a typed
|
|
@@ -599,8 +559,7 @@ class Http {
|
|
|
599
559
|
return result;
|
|
600
560
|
}
|
|
601
561
|
finally {
|
|
602
|
-
|
|
603
|
-
subscription.removeEventListener("abort", onAbort);
|
|
562
|
+
subscription.removeEventListener("abort", onAbort);
|
|
604
563
|
}
|
|
605
564
|
}
|
|
606
565
|
// {§http-manifest}/{§http-lifecycle} Seed the declared channel shape before
|
|
@@ -611,13 +570,18 @@ class Http {
|
|
|
611
570
|
mimetype,
|
|
612
571
|
state: "active",
|
|
613
572
|
}]));
|
|
614
|
-
return { channels
|
|
573
|
+
return { channels };
|
|
615
574
|
}
|
|
616
|
-
// {§revalidation}
|
|
617
|
-
//
|
|
618
|
-
// representation, regardless of whether partial content is non-empty.
|
|
575
|
+
// {§revalidation} The body and header must have settled successfully. An
|
|
576
|
+
// auxiliary channel may have terminally failed without invalidating body.
|
|
619
577
|
static #representationComplete(entry) {
|
|
620
|
-
|
|
578
|
+
const bodyState = entry.channels[BODY]?.state;
|
|
579
|
+
const headerState = entry.channels[HEADER]?.state;
|
|
580
|
+
const successful = (state) => state === "static" || state === "closed";
|
|
581
|
+
const terminal = (state) => successful(state) || state === "errored";
|
|
582
|
+
return successful(bodyState)
|
|
583
|
+
&& successful(headerState)
|
|
584
|
+
&& Object.values(entry.channels).every(({ state }) => terminal(state));
|
|
621
585
|
}
|
|
622
586
|
// {§revalidation} Direct READ and exact FIND share the complete reusable
|
|
623
587
|
// representation predicate; freshness only decides how READ uses a match.
|
|
@@ -636,13 +600,16 @@ class Http {
|
|
|
636
600
|
// {§revalidation} Origin headers come first; authoritative package method
|
|
637
601
|
// and acquisition metadata are appended last.
|
|
638
602
|
static async #writeHeader(subscription, method, status, statusText, responseHeaders, requestHeaders) {
|
|
603
|
+
await subscription.notifyChunk(HEADER, _a.#responseHeader(method, status, statusText, responseHeaders, requestHeaders), "text/plain");
|
|
604
|
+
}
|
|
605
|
+
static #responseHeader(method, status, statusText, responseHeaders, requestHeaders) {
|
|
639
606
|
const lines = [`HTTP ${status} ${statusText}`];
|
|
640
607
|
for (const [k, v] of responseHeaders)
|
|
641
608
|
lines.push(`${k}: ${v}`);
|
|
642
609
|
lines.push(`${REQUEST_METHOD}: ${method}`);
|
|
643
610
|
lines.push(`${FETCHED_AT}: ${new Date().toISOString()}`);
|
|
644
611
|
lines.push(cacheVariantEvidence(classifyCacheVariant(requestHeaders, responseHeaders)));
|
|
645
|
-
|
|
612
|
+
return lines.join("\n");
|
|
646
613
|
}
|
|
647
614
|
static async #writeProjectionIdentity(subscription, identity) {
|
|
648
615
|
await subscription.notifyChunk(HEADER, `\n${WebFetcher.projectionEvidence(identity)}`, "text/plain");
|
|
@@ -652,6 +619,11 @@ class Http {
|
|
|
652
619
|
static async #settleEventStream(subscription, response, options) {
|
|
653
620
|
try {
|
|
654
621
|
const events = await _a.#streamEvents(subscription, response);
|
|
622
|
+
if (options.signal.aborted) {
|
|
623
|
+
const result = _a.#cancelled(options.url, options.method);
|
|
624
|
+
await subscription.close(result, result.problem?.detail);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
655
627
|
await subscription.close({ status: 200 }, `SSE stream; ${events} events`);
|
|
656
628
|
}
|
|
657
629
|
catch (error) {
|
|
@@ -873,6 +845,9 @@ class Http {
|
|
|
873
845
|
static #projectionIdentity(priorHeader) {
|
|
874
846
|
return _a.#packageHeaderValue(priorHeader, PROJECTION_ID_HEADER);
|
|
875
847
|
}
|
|
848
|
+
static #materializerIdentity(priorHeader) {
|
|
849
|
+
return _a.#packageHeaderValue(priorHeader, MATERIALIZER_ID_HEADER);
|
|
850
|
+
}
|
|
876
851
|
static #cacheVariant(priorHeader) {
|
|
877
852
|
const value = _a.#packageHeaderValue(priorHeader, CACHE_VARIANT_HEADER);
|
|
878
853
|
return value === "default" || value === "bypass" ? value : undefined;
|
|
@@ -881,10 +856,22 @@ class Http {
|
|
|
881
856
|
return responseMimetype(lastHeaderValue(header, "content-type") ?? null);
|
|
882
857
|
}
|
|
883
858
|
static async #projectionCurrent(header, projection) {
|
|
859
|
+
const sourceMimetype = _a.#sourceMimetype(header);
|
|
860
|
+
const isHtml = MimetypeClassifier.isHtml(sourceMimetype);
|
|
861
|
+
const materializerIdentity = _a.#materializerIdentity(header);
|
|
862
|
+
if (materializerIdentity !== undefined) {
|
|
863
|
+
if (!WebFetcher.materializerCurrent(materializerIdentity))
|
|
864
|
+
return false;
|
|
865
|
+
if (materializerIdentity.startsWith("tavily-extract:")
|
|
866
|
+
|| materializerIdentity === "origin-markdown:v1")
|
|
867
|
+
return true;
|
|
868
|
+
}
|
|
869
|
+
if (isHtml && materializerIdentity === undefined)
|
|
870
|
+
return false;
|
|
884
871
|
const storedIdentity = _a.#projectionIdentity(header);
|
|
885
872
|
if (storedIdentity === undefined)
|
|
886
|
-
return
|
|
887
|
-
return await projection.identity(
|
|
873
|
+
return !isHtml;
|
|
874
|
+
return await projection.identity(sourceMimetype) === storedIdentity;
|
|
888
875
|
}
|
|
889
876
|
// RFC 9111 §3.2 permits a processed cache to retain the metadata that its
|
|
890
877
|
// stored body depends upon. Other 304 fields update by name; package-owned
|
|
@@ -899,7 +886,15 @@ class Http {
|
|
|
899
886
|
...retained.map(({ name, value }) => [name, value]),
|
|
900
887
|
...updates,
|
|
901
888
|
];
|
|
902
|
-
const
|
|
889
|
+
const packageEvidence = [
|
|
890
|
+
MATERIALIZER_ID_HEADER,
|
|
891
|
+
PROJECTION_ID_HEADER,
|
|
892
|
+
TAVILY_REQUEST_ID_HEADER,
|
|
893
|
+
TAVILY_CREDITS_HEADER,
|
|
894
|
+
].flatMap((field) => {
|
|
895
|
+
const value = _a.#packageHeaderValue(header, field);
|
|
896
|
+
return value === undefined ? [] : [`${field}: ${value}`];
|
|
897
|
+
});
|
|
903
898
|
return [
|
|
904
899
|
lines[0] ?? "HTTP 200 OK",
|
|
905
900
|
...retained.map(({ line }) => line),
|
|
@@ -907,9 +902,7 @@ class Http {
|
|
|
907
902
|
`${REQUEST_METHOD}: GET`,
|
|
908
903
|
`${FETCHED_AT}: ${new Date().toISOString()}`,
|
|
909
904
|
cacheVariantEvidence(classifyCacheVariant(requestHeaders, merged)),
|
|
910
|
-
...
|
|
911
|
-
? []
|
|
912
|
-
: [WebFetcher.projectionEvidence(projectionIdentity)]),
|
|
905
|
+
...packageEvidence,
|
|
913
906
|
].join("\n");
|
|
914
907
|
}
|
|
915
908
|
static #revalidationCorresponds(priorHeader, responseHeaders) {
|
|
@@ -979,20 +972,10 @@ class Http {
|
|
|
979
972
|
});
|
|
980
973
|
}
|
|
981
974
|
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.`, {
|
|
975
|
+
return _a.#bad(500, "http", "projection-failed", `HTTP ${method} ${url} acquired content, but its readable projection failed.`, {
|
|
986
976
|
target: url,
|
|
987
977
|
method,
|
|
988
978
|
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
979
|
stage: "projection",
|
|
997
980
|
retryable: false,
|
|
998
981
|
});
|