@stll/fetch 0.0.1-placeholder.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/dist/index-DNw-Gzhr.d.ts +32 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +158 -0
- package/dist/publisher.d.ts +124 -0
- package/dist/publisher.js +670 -0
- package/package.json +61 -3
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# `@stll/fetch`
|
|
2
|
+
|
|
3
|
+
`fetch` with a timeout you cannot forget, plus transport-failure helpers.
|
|
4
|
+
|
|
5
|
+
## `fetchWithTimeout`
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { fetchWithTimeout } from "@stll/fetch";
|
|
9
|
+
|
|
10
|
+
// Abort when no response headers arrive within 5 seconds.
|
|
11
|
+
const response = await fetchWithTimeout(url, {
|
|
12
|
+
timeout: { type: "headers", ms: 5_000 },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Abort when the headers, or any later body chunk, take longer than
|
|
16
|
+
// 10 seconds: a slow but progressing download keeps going.
|
|
17
|
+
const stream = await fetchWithTimeout(url, {
|
|
18
|
+
timeout: { type: "idle", ms: 10_000 },
|
|
19
|
+
signal: controller.signal, // optional; either signal aborts the request
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The timeout is required by the type. `headers` bounds the wait for the
|
|
24
|
+
response; `idle` also restarts on every body chunk and errors the body stream
|
|
25
|
+
when it stalls. A timeout rejects (or errors the body) with a `TimeoutError`
|
|
26
|
+
`DOMException`, and the caller's `signal` and a `Request`'s own signal still
|
|
27
|
+
abort the request. `timeoutMs: number` is a total deadline over the request
|
|
28
|
+
and its body.
|
|
29
|
+
|
|
30
|
+
`createFetchWithTimeout(fetcher)` returns the same function over another
|
|
31
|
+
`fetch` implementation (a proxy-aware client, or a fake in tests).
|
|
32
|
+
|
|
33
|
+
`isConnectionFailure(error)` reports whether an error, or any error in its
|
|
34
|
+
`cause` chain, is a connection-level failure (refused, reset, DNS, TLS
|
|
35
|
+
certificate, socket and connect timeouts) across Bun, Node and undici.
|
|
36
|
+
|
|
37
|
+
## `@stll/fetch/publisher`
|
|
38
|
+
|
|
39
|
+
This entry needs Bun: it waits between requests with `Bun.sleep`.
|
|
40
|
+
|
|
41
|
+
`createPolitePublisherFetch({ validateTarget, fetcher })` fetches from public
|
|
42
|
+
websites politely: it reads and caches each origin's `robots.txt`, honours
|
|
43
|
+
`Disallow` and `Crawl-delay` rules, spaces requests per origin, follows at most
|
|
44
|
+
five redirects (validating each hop with `validateTarget`), and returns refusals
|
|
45
|
+
as typed `better-result` errors. Requests identify as
|
|
46
|
+
`Stella/1.0 (+https://stll.app)`, the product token its robots evaluation
|
|
47
|
+
uses. `readBoundedBody(response, { maxBytes, signal })` reads a body with a
|
|
48
|
+
size cap.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/connection-failure.d.ts
|
|
2
|
+
declare const isConnectionFailure: (cause: unknown) => boolean;
|
|
3
|
+
//#endregion
|
|
4
|
+
//#region src/index.d.ts
|
|
5
|
+
type FetchTimeout = {
|
|
6
|
+
type: "headers";
|
|
7
|
+
ms: number;
|
|
8
|
+
} | {
|
|
9
|
+
type: "idle";
|
|
10
|
+
ms: number;
|
|
11
|
+
};
|
|
12
|
+
type TransportRequestInit = RequestInit & {
|
|
13
|
+
timeout?: FetchTimeout;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
};
|
|
16
|
+
type Fetcher = (input: string | URL | Request, init?: TransportRequestInit) => Promise<Response>;
|
|
17
|
+
type FetchWithTimeoutInit = Omit<RequestInit, "signal"> & {
|
|
18
|
+
signal?: AbortSignal | undefined;
|
|
19
|
+
} & ({
|
|
20
|
+
timeout: FetchTimeout;
|
|
21
|
+
timeoutMs?: never;
|
|
22
|
+
} | {
|
|
23
|
+
/** Choose a headers or idle timeout for new callers. */
|
|
24
|
+
timeoutMs: number;
|
|
25
|
+
timeout?: never;
|
|
26
|
+
});
|
|
27
|
+
/** Creates a wrapper with the supplied transport and an explicit timeout policy. */
|
|
28
|
+
declare const createFetchWithTimeout: (fetcher: Fetcher) => (input: string | URL | Request, init: FetchWithTimeoutInit) => Promise<Response>;
|
|
29
|
+
/** Uses the current global fetch implementation at request time. */
|
|
30
|
+
declare const fetchWithTimeout: (input: string | URL | Request, init: FetchWithTimeoutInit) => Promise<Response>;
|
|
31
|
+
//#endregion
|
|
32
|
+
export { createFetchWithTimeout as a, TransportRequestInit as i, FetchWithTimeoutInit as n, fetchWithTimeout as o, Fetcher as r, isConnectionFailure as s, FetchTimeout as t };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as createFetchWithTimeout, i as TransportRequestInit, n as FetchWithTimeoutInit, o as fetchWithTimeout, r as Fetcher, s as isConnectionFailure, t as FetchTimeout } from "./index-DNw-Gzhr.js";
|
|
2
|
+
export { FetchTimeout, FetchWithTimeoutInit, Fetcher, TransportRequestInit, createFetchWithTimeout, fetchWithTimeout, isConnectionFailure };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
//#region src/connection-failure.ts
|
|
2
|
+
const CONNECTION_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
3
|
+
"ConnectionRefused",
|
|
4
|
+
"FailedToOpenSocket",
|
|
5
|
+
"ConnectionClosed",
|
|
6
|
+
"ConnectionTimeout",
|
|
7
|
+
"SocketTimeout",
|
|
8
|
+
"EPIPE",
|
|
9
|
+
"ENOTFOUND",
|
|
10
|
+
"EAI_AGAIN",
|
|
11
|
+
"ECONNREFUSED",
|
|
12
|
+
"ECONNRESET",
|
|
13
|
+
"ETIMEDOUT",
|
|
14
|
+
"ESOCKETTIMEDOUT",
|
|
15
|
+
"EHOSTUNREACH",
|
|
16
|
+
"ENETUNREACH",
|
|
17
|
+
"ECONNABORTED",
|
|
18
|
+
"EPROTO",
|
|
19
|
+
"ERR_SOCKET_CLOSED",
|
|
20
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
21
|
+
"UND_ERR_SOCKET",
|
|
22
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
23
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
24
|
+
"ERR_TLS_CERT_SIGNATURE_ALGORITHM_UNSUPPORTED",
|
|
25
|
+
"CERT_NOT_YET_VALID",
|
|
26
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
27
|
+
"INVALID_CA",
|
|
28
|
+
"HOSTNAME_MISMATCH",
|
|
29
|
+
"ERR_TLS_CERT_ALTNAME_INVALID",
|
|
30
|
+
"CERT_HAS_EXPIRED",
|
|
31
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
32
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
33
|
+
"UNABLE_TO_GET_ISSUER_CERT",
|
|
34
|
+
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY"
|
|
35
|
+
]);
|
|
36
|
+
const isConnectionFailure = (cause) => {
|
|
37
|
+
const visited = /* @__PURE__ */ new Set();
|
|
38
|
+
const codes = [];
|
|
39
|
+
const messages = [];
|
|
40
|
+
let current = cause;
|
|
41
|
+
while ((typeof Error.isError === "function" && Error.isError(current) || current instanceof Error) && !visited.has(current)) {
|
|
42
|
+
visited.add(current);
|
|
43
|
+
if (current.name === "AbortError" || current.name === "TimeoutError") return false;
|
|
44
|
+
if ("code" in current && typeof current.code === "string") codes.push(current.code);
|
|
45
|
+
messages.push(current.message);
|
|
46
|
+
current = current.cause;
|
|
47
|
+
}
|
|
48
|
+
if (codes.length > 0) return codes.some((code) => CONNECTION_ERROR_CODES.has(code) || code.startsWith("ERR_SSL_") || code.startsWith("ERR_TLS_"));
|
|
49
|
+
return messages.some((message) => /^(?:Unable to connect|Failed to fetch|fetch failed|NetworkError|Load failed|TLS handshake failed|DNS lookup failed)/iu.test(message));
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/index.ts
|
|
53
|
+
const executeFetchWithTimeout = async (fetcher, input, { timeout, timeoutMs, signal, ...init }) => {
|
|
54
|
+
const callerSignals = [signal, input instanceof Request ? input.signal : void 0].filter((candidate) => candidate !== void 0);
|
|
55
|
+
const activeFetcher = fetcher ?? globalThis.fetch;
|
|
56
|
+
if (timeout === void 0) {
|
|
57
|
+
const total = AbortSignal.timeout(timeoutMs);
|
|
58
|
+
return await activeFetcher(input, {
|
|
59
|
+
...init,
|
|
60
|
+
signal: AbortSignal.any([...callerSignals, total])
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (!Number.isFinite(timeout.ms) || timeout.ms < 0 || timeout.ms > 2147483647) throw new DOMException("Invalid fetch timeout duration", "NotSupportedError");
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const combined = AbortSignal.any([...callerSignals, controller.signal]);
|
|
66
|
+
const expire = () => controller.abort(new DOMException("Fetch timed out", "TimeoutError"));
|
|
67
|
+
let timer;
|
|
68
|
+
const clear = () => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
timer = void 0;
|
|
71
|
+
};
|
|
72
|
+
const reset = () => {
|
|
73
|
+
clear();
|
|
74
|
+
timer = setTimeout(expire, timeout.ms);
|
|
75
|
+
};
|
|
76
|
+
reset();
|
|
77
|
+
const response = await Promise.resolve().then(async () => await activeFetcher(input, {
|
|
78
|
+
...init,
|
|
79
|
+
signal: combined
|
|
80
|
+
})).finally(clear);
|
|
81
|
+
if (combined.aborted) {
|
|
82
|
+
await Promise.allSettled([response.body?.cancel(combined.reason)]);
|
|
83
|
+
throw combined.reason;
|
|
84
|
+
}
|
|
85
|
+
if (timeout.type === "headers" || response.body === null) return response;
|
|
86
|
+
const reader = response.body.getReader();
|
|
87
|
+
let stopped = false;
|
|
88
|
+
let cancellation;
|
|
89
|
+
let abort;
|
|
90
|
+
const finish = () => {
|
|
91
|
+
stopped = true;
|
|
92
|
+
clear();
|
|
93
|
+
combined.removeEventListener("abort", abort);
|
|
94
|
+
};
|
|
95
|
+
const body = new ReadableStream({
|
|
96
|
+
start(stream) {
|
|
97
|
+
abort = () => {
|
|
98
|
+
if (stopped) return;
|
|
99
|
+
finish();
|
|
100
|
+
stream.error(combined.reason);
|
|
101
|
+
cancellation = reader.cancel(combined.reason).then(() => reader.releaseLock(), () => reader.releaseLock());
|
|
102
|
+
};
|
|
103
|
+
combined.addEventListener("abort", abort, { once: true });
|
|
104
|
+
if (combined.aborted) {
|
|
105
|
+
abort();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
async pull(stream) {
|
|
110
|
+
if (stopped) {
|
|
111
|
+
await cancellation;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
reset();
|
|
115
|
+
await reader.read().finally(clear).then(({ done, value }) => {
|
|
116
|
+
if (stopped) return;
|
|
117
|
+
if (done) {
|
|
118
|
+
finish();
|
|
119
|
+
reader.releaseLock();
|
|
120
|
+
stream.close();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
stream.enqueue(value);
|
|
124
|
+
}, (error) => {
|
|
125
|
+
if (stopped) return;
|
|
126
|
+
finish();
|
|
127
|
+
reader.releaseLock();
|
|
128
|
+
stream.error(error);
|
|
129
|
+
});
|
|
130
|
+
await cancellation;
|
|
131
|
+
},
|
|
132
|
+
async cancel(reason) {
|
|
133
|
+
if (stopped) {
|
|
134
|
+
await cancellation;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
finish();
|
|
138
|
+
await reader.cancel(reason).finally(() => reader.releaseLock());
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
const wrapped = new Response(body, {
|
|
142
|
+
status: response.status,
|
|
143
|
+
statusText: response.statusText,
|
|
144
|
+
headers: response.headers
|
|
145
|
+
});
|
|
146
|
+
Object.defineProperties(wrapped, {
|
|
147
|
+
url: { value: response.url },
|
|
148
|
+
redirected: { value: response.redirected },
|
|
149
|
+
type: { value: response.type }
|
|
150
|
+
});
|
|
151
|
+
return wrapped;
|
|
152
|
+
};
|
|
153
|
+
/** Creates a wrapper with the supplied transport and an explicit timeout policy. */
|
|
154
|
+
const createFetchWithTimeout = (fetcher) => async (input, init) => await executeFetchWithTimeout(fetcher, input, init);
|
|
155
|
+
/** Uses the current global fetch implementation at request time. */
|
|
156
|
+
const fetchWithTimeout = async (input, init) => await executeFetchWithTimeout(void 0, input, init);
|
|
157
|
+
//#endregion
|
|
158
|
+
export { createFetchWithTimeout, fetchWithTimeout, isConnectionFailure };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { i as TransportRequestInit } from "./index-DNw-Gzhr.js";
|
|
2
|
+
import { Result } from "better-result";
|
|
3
|
+
//#region src/publisher.d.ts
|
|
4
|
+
export type PublisherPolicy = {
|
|
5
|
+
type: "obey";
|
|
6
|
+
} | {
|
|
7
|
+
type: "licensed";
|
|
8
|
+
basis: string;
|
|
9
|
+
evidenceUrl: string;
|
|
10
|
+
};
|
|
11
|
+
export type PublisherRobotsDecision = "allowed" | "allow_all_4xx" | "robots_disallowed" | "agent_excluded" | "robots_unavailable" | "licensed_bypass";
|
|
12
|
+
type RefusalFields = {
|
|
13
|
+
message: string;
|
|
14
|
+
url: string;
|
|
15
|
+
origin: string;
|
|
16
|
+
};
|
|
17
|
+
declare const RobotsDisallowedError_base: import("better-result").TaggedErrorClass<"robots_disallowed">;
|
|
18
|
+
export declare class RobotsDisallowedError extends RobotsDisallowedError_base<RefusalFields> {}
|
|
19
|
+
declare const AgentExcludedError_base: import("better-result").TaggedErrorClass<"agent_excluded">;
|
|
20
|
+
export declare class AgentExcludedError extends AgentExcludedError_base<RefusalFields> {}
|
|
21
|
+
declare const RobotsUnavailableError_base: import("better-result").TaggedErrorClass<"robots_unavailable">;
|
|
22
|
+
export declare class RobotsUnavailableError extends RobotsUnavailableError_base<RefusalFields & {
|
|
23
|
+
cause?: unknown;
|
|
24
|
+
}> {}
|
|
25
|
+
declare const CrawlDelayUnsupportedError_base: import("better-result").TaggedErrorClass<"crawl_delay_unsupported">;
|
|
26
|
+
export declare class CrawlDelayUnsupportedError extends CrawlDelayUnsupportedError_base<RefusalFields & {
|
|
27
|
+
crawlDelayMs: number;
|
|
28
|
+
maximumMs: number;
|
|
29
|
+
}> {}
|
|
30
|
+
declare const PublisherRedirectLimitError_base: import("better-result").TaggedErrorClass<"publisher_redirect_limit">;
|
|
31
|
+
export declare class PublisherRedirectLimitError extends PublisherRedirectLimitError_base<RefusalFields> {}
|
|
32
|
+
declare const PublisherTargetRefusedError_base: import("better-result").TaggedErrorClass<"publisher_target_refused">;
|
|
33
|
+
export declare class PublisherTargetRefusedError extends PublisherTargetRefusedError_base<RefusalFields & {
|
|
34
|
+
cause: unknown;
|
|
35
|
+
}> {}
|
|
36
|
+
declare const PublisherBodyNotReplayableError_base: import("better-result").TaggedErrorClass<"publisher_body_not_replayable">;
|
|
37
|
+
export declare class PublisherBodyNotReplayableError extends PublisherBodyNotReplayableError_base<RefusalFields> {}
|
|
38
|
+
declare const PublisherRequestAbortedError_base: import("better-result").TaggedErrorClass<"publisher_request_aborted">;
|
|
39
|
+
export declare class PublisherRequestAbortedError extends PublisherRequestAbortedError_base<RefusalFields & {
|
|
40
|
+
cause: unknown;
|
|
41
|
+
}> {}
|
|
42
|
+
declare const PublisherBypassUnauditedError_base: import("better-result").TaggedErrorClass<"publisher_bypass_unaudited">;
|
|
43
|
+
/** A licensed bypass is evidence that must be recorded; without a sink it is refused. */
|
|
44
|
+
export declare class PublisherBypassUnauditedError extends PublisherBypassUnauditedError_base<RefusalFields & {
|
|
45
|
+
cause: unknown;
|
|
46
|
+
}> {}
|
|
47
|
+
export type PublisherPolicyError = RobotsDisallowedError | AgentExcludedError | RobotsUnavailableError | CrawlDelayUnsupportedError | PublisherRedirectLimitError | PublisherTargetRefusedError | PublisherBodyNotReplayableError | PublisherRequestAbortedError | PublisherBypassUnauditedError;
|
|
48
|
+
export type PublisherFetchResult = {
|
|
49
|
+
response: Response;
|
|
50
|
+
robotsDecision: PublisherRobotsDecision;
|
|
51
|
+
delayAppliedMs: number;
|
|
52
|
+
};
|
|
53
|
+
export type PublisherTelemetry = {
|
|
54
|
+
event: "publisher_fetch_policy_bypass";
|
|
55
|
+
url: string;
|
|
56
|
+
origin: string;
|
|
57
|
+
refusal: PublisherPolicyError;
|
|
58
|
+
policy: PublisherPolicy;
|
|
59
|
+
delayMs: number;
|
|
60
|
+
};
|
|
61
|
+
type Clock = {
|
|
62
|
+
now: () => number;
|
|
63
|
+
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
64
|
+
};
|
|
65
|
+
type PublisherTarget = {
|
|
66
|
+
url: URL;
|
|
67
|
+
};
|
|
68
|
+
type PublisherFetcher<Target extends PublisherTarget> = (target: Target, init?: TransportRequestInit) => Promise<Response>;
|
|
69
|
+
type PublisherFetcherOptions<Target extends PublisherTarget> = {
|
|
70
|
+
validateTarget: (url: URL) => Promise<Result<Target, unknown>>;
|
|
71
|
+
fetcher: PublisherFetcher<Target>;
|
|
72
|
+
clock?: Clock;
|
|
73
|
+
robotsTtlMs?: number;
|
|
74
|
+
robotsRetryMs?: number;
|
|
75
|
+
defaultMinimumIntervalMs?: number;
|
|
76
|
+
robotsLoadTimeoutMs?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Audit sink for licensed bypasses, awaited before the bypass proceeds;
|
|
79
|
+
* without it, or when it throws or rejects, the bypass is refused.
|
|
80
|
+
*/
|
|
81
|
+
onTelemetry?: (event: PublisherTelemetry) => void | Promise<void>;
|
|
82
|
+
};
|
|
83
|
+
type PublisherFetchOptions = {
|
|
84
|
+
init?: Omit<TransportRequestInit, "redirect">;
|
|
85
|
+
policy?: PublisherPolicy;
|
|
86
|
+
};
|
|
87
|
+
type CompiledPattern = {
|
|
88
|
+
anchored: boolean;
|
|
89
|
+
segments: {
|
|
90
|
+
literal: string;
|
|
91
|
+
prefix: number[];
|
|
92
|
+
}[];
|
|
93
|
+
specificity: number;
|
|
94
|
+
};
|
|
95
|
+
type Rule = {
|
|
96
|
+
type: "allow" | "disallow";
|
|
97
|
+
pattern: string;
|
|
98
|
+
compiled?: CompiledPattern;
|
|
99
|
+
};
|
|
100
|
+
type Group = {
|
|
101
|
+
agents: string[];
|
|
102
|
+
rules: Rule[];
|
|
103
|
+
crawlDelayMs?: number;
|
|
104
|
+
};
|
|
105
|
+
/** Every robots refusal a licensed policy may bypass, each only with an audit record. */
|
|
106
|
+
export type PublisherBypassableRefusal = RobotsUnavailableError | CrawlDelayUnsupportedError | AgentExcludedError | RobotsDisallowedError;
|
|
107
|
+
export declare const evaluatePublisherRobots: (groups: Group[], productToken: string, path: string) => {
|
|
108
|
+
allowed: boolean;
|
|
109
|
+
agentExcluded: boolean;
|
|
110
|
+
crawlDelayMs: number;
|
|
111
|
+
};
|
|
112
|
+
declare const PublisherBodyLimitError_base: import("better-result").TaggedErrorClass<"publisher_body_limit">;
|
|
113
|
+
export declare class PublisherBodyLimitError extends PublisherBodyLimitError_base<{
|
|
114
|
+
message: string;
|
|
115
|
+
url: string;
|
|
116
|
+
maxBytes: number;
|
|
117
|
+
}> {}
|
|
118
|
+
type ReadBoundedBodyOptions = {
|
|
119
|
+
maxBytes: number;
|
|
120
|
+
signal: AbortSignal;
|
|
121
|
+
};
|
|
122
|
+
export declare const readBoundedBody: (response: Response, { maxBytes, signal }: ReadBoundedBodyOptions) => Promise<Result<Uint8Array, PublisherBodyLimitError>>;
|
|
123
|
+
export declare const createPolitePublisherFetch: <Target extends PublisherTarget>({ validateTarget, fetcher, clock, robotsTtlMs, robotsRetryMs, robotsLoadTimeoutMs, defaultMinimumIntervalMs, onTelemetry }: PublisherFetcherOptions<Target>) => (input: string | URL, options?: PublisherFetchOptions) => Promise<Result<PublisherFetchResult, PublisherPolicyError>>;
|
|
124
|
+
//#endregion
|
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { Result, TaggedError } from "better-result";
|
|
2
|
+
//#region src/publisher.ts
|
|
3
|
+
const ROBOTS_MAX_BYTES = 524288;
|
|
4
|
+
const ROBOTS_TTL_MS = 36e5;
|
|
5
|
+
const ROBOTS_RETRY_MS = 6e4;
|
|
6
|
+
const DEFAULT_MINIMUM_INTERVAL_MS = 1e3;
|
|
7
|
+
const MAX_CRAWL_DELAY_MS = 6e4;
|
|
8
|
+
const ROBOTS_LOAD_TIMEOUT_MS = 1e4;
|
|
9
|
+
const MAX_REDIRECTS = 5;
|
|
10
|
+
const PRODUCT_TOKEN = "Stella";
|
|
11
|
+
const PUBLISHER_USER_AGENT = `${PRODUCT_TOKEN}/1.0 (+https://stll.app)`;
|
|
12
|
+
var RobotsDisallowedError = class extends TaggedError("robots_disallowed") {};
|
|
13
|
+
var AgentExcludedError = class extends TaggedError("agent_excluded") {};
|
|
14
|
+
var RobotsUnavailableError = class extends TaggedError("robots_unavailable") {};
|
|
15
|
+
var CrawlDelayUnsupportedError = class extends TaggedError("crawl_delay_unsupported") {};
|
|
16
|
+
var PublisherRedirectLimitError = class extends TaggedError("publisher_redirect_limit") {};
|
|
17
|
+
var PublisherTargetRefusedError = class extends TaggedError("publisher_target_refused") {};
|
|
18
|
+
var PublisherBodyNotReplayableError = class extends TaggedError("publisher_body_not_replayable") {};
|
|
19
|
+
var PublisherRequestAbortedError = class extends TaggedError("publisher_request_aborted") {};
|
|
20
|
+
/** A licensed bypass is evidence that must be recorded; without a sink it is refused. */
|
|
21
|
+
var PublisherBypassUnauditedError = class extends TaggedError("publisher_bypass_unaudited") {};
|
|
22
|
+
const publisherPolicyFor = (state, url, defaultMinimumIntervalMs) => {
|
|
23
|
+
if (state.type === "unavailable") return {
|
|
24
|
+
intervalMs: defaultMinimumIntervalMs,
|
|
25
|
+
refusal: new RobotsUnavailableError({
|
|
26
|
+
message: "robots.txt is unavailable",
|
|
27
|
+
url: url.href,
|
|
28
|
+
origin: url.origin,
|
|
29
|
+
cause: state.cause
|
|
30
|
+
})
|
|
31
|
+
};
|
|
32
|
+
if (state.type === "allow-all") return {
|
|
33
|
+
decision: "allow_all_4xx",
|
|
34
|
+
intervalMs: defaultMinimumIntervalMs
|
|
35
|
+
};
|
|
36
|
+
const evaluated = evaluatePublisherRobots(state.groups, PRODUCT_TOKEN, `${url.pathname}${url.search}`);
|
|
37
|
+
if (evaluated.crawlDelayMs > MAX_CRAWL_DELAY_MS) return {
|
|
38
|
+
intervalMs: evaluated.crawlDelayMs,
|
|
39
|
+
refusal: new CrawlDelayUnsupportedError({
|
|
40
|
+
message: "robots.txt crawl delay exceeds the supported maximum",
|
|
41
|
+
url: url.href,
|
|
42
|
+
origin: url.origin,
|
|
43
|
+
crawlDelayMs: evaluated.crawlDelayMs,
|
|
44
|
+
maximumMs: MAX_CRAWL_DELAY_MS
|
|
45
|
+
})
|
|
46
|
+
};
|
|
47
|
+
let refusal;
|
|
48
|
+
if (!evaluated.allowed) refusal = evaluated.agentExcluded ? new AgentExcludedError({
|
|
49
|
+
message: "robots.txt excludes the stella agent",
|
|
50
|
+
url: url.href,
|
|
51
|
+
origin: url.origin
|
|
52
|
+
}) : new RobotsDisallowedError({
|
|
53
|
+
message: "robots.txt disallows this URL",
|
|
54
|
+
url: url.href,
|
|
55
|
+
origin: url.origin
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
intervalMs: Math.max(defaultMinimumIntervalMs, evaluated.crawlDelayMs),
|
|
59
|
+
...refusal ? { refusal } : {}
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
const normalizeOctets = (value) => {
|
|
63
|
+
const percentNormalized = value.replaceAll(/%[\da-f]{2}/giu, (encoded) => {
|
|
64
|
+
const octet = Number.parseInt(encoded.slice(1), 16);
|
|
65
|
+
const character = String.fromCodePoint(octet);
|
|
66
|
+
return /^[A-Za-z0-9._~-]$/u.test(character) ? character : `%${encoded.slice(1).toUpperCase()}`;
|
|
67
|
+
});
|
|
68
|
+
let normalized = "";
|
|
69
|
+
for (const character of percentNormalized) {
|
|
70
|
+
if ((character.codePointAt(0) ?? Number.POSITIVE_INFINITY) <= 127) {
|
|
71
|
+
normalized += character;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
for (const octet of new TextEncoder().encode(character)) normalized += `%${octet.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
75
|
+
}
|
|
76
|
+
return normalized;
|
|
77
|
+
};
|
|
78
|
+
const parseRobots = (body) => {
|
|
79
|
+
const groups = [];
|
|
80
|
+
let current;
|
|
81
|
+
let sawRule = false;
|
|
82
|
+
for (const rawLine of body.split(/\r\n|\r|\n/u)) {
|
|
83
|
+
const comment = rawLine.indexOf("#");
|
|
84
|
+
const line = rawLine.slice(0, comment === -1 ? void 0 : comment).trim();
|
|
85
|
+
const separator = line.indexOf(":");
|
|
86
|
+
if (separator === -1) continue;
|
|
87
|
+
const field = line.slice(0, separator).trim().toLowerCase();
|
|
88
|
+
const value = line.slice(separator + 1).trim();
|
|
89
|
+
if (field === "user-agent") {
|
|
90
|
+
if (!current || sawRule) {
|
|
91
|
+
current = {
|
|
92
|
+
agents: [],
|
|
93
|
+
rules: []
|
|
94
|
+
};
|
|
95
|
+
groups.push(current);
|
|
96
|
+
sawRule = false;
|
|
97
|
+
}
|
|
98
|
+
current.agents.push(value.toLowerCase());
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!current || current.agents.length === 0) continue;
|
|
102
|
+
if (field === "allow" || field === "disallow") {
|
|
103
|
+
sawRule = true;
|
|
104
|
+
if (value !== "") current.rules.push({
|
|
105
|
+
type: field,
|
|
106
|
+
pattern: value,
|
|
107
|
+
compiled: compilePattern(value)
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (field === "crawl-delay") {
|
|
112
|
+
const seconds = Number(value);
|
|
113
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
114
|
+
sawRule = true;
|
|
115
|
+
current.crawlDelayMs = seconds * 1e3;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return groups;
|
|
120
|
+
};
|
|
121
|
+
const selectedGroups = (groups, productToken) => {
|
|
122
|
+
const token = productToken.toLowerCase();
|
|
123
|
+
const named = groups.filter(({ agents }) => agents.includes(token));
|
|
124
|
+
return named.length > 0 ? named : groups.filter(({ agents }) => agents.includes("*"));
|
|
125
|
+
};
|
|
126
|
+
const compilePattern = (pattern) => {
|
|
127
|
+
const normalized = normalizeOctets(pattern);
|
|
128
|
+
const anchored = normalized.endsWith("$");
|
|
129
|
+
const body = anchored ? normalized.slice(0, -1) : normalized;
|
|
130
|
+
return {
|
|
131
|
+
anchored,
|
|
132
|
+
segments: body.split("*").map((literal) => {
|
|
133
|
+
const prefix = Array.from({ length: literal.length }, () => 0);
|
|
134
|
+
for (let index = 1, matched = 0; index < literal.length; index += 1) {
|
|
135
|
+
while (matched > 0 && literal[index] !== literal[matched]) matched = prefix.at(matched - 1) ?? 0;
|
|
136
|
+
if (literal[index] === literal[matched]) matched += 1;
|
|
137
|
+
prefix[index] = matched;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
literal,
|
|
141
|
+
prefix
|
|
142
|
+
};
|
|
143
|
+
}),
|
|
144
|
+
specificity: body.replaceAll(/%[\dA-F]{2}/gu, "%").length
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
const findSegment = (value, start, segment) => {
|
|
148
|
+
if (segment.literal.length === 0) return start;
|
|
149
|
+
for (let index = start, matched = 0; index < value.length; index += 1) {
|
|
150
|
+
while (matched > 0 && value[index] !== segment.literal[matched]) matched = segment.prefix.at(matched - 1) ?? 0;
|
|
151
|
+
if (value[index] === segment.literal[matched]) matched += 1;
|
|
152
|
+
if (matched === segment.literal.length) return index - matched + 1;
|
|
153
|
+
}
|
|
154
|
+
return -1;
|
|
155
|
+
};
|
|
156
|
+
const wildcardMatches = (compiled, value) => {
|
|
157
|
+
const first = compiled.segments.at(0);
|
|
158
|
+
if (!first) return false;
|
|
159
|
+
if (!value.startsWith(first.literal)) return false;
|
|
160
|
+
let position = first.literal.length;
|
|
161
|
+
const lastIndex = compiled.segments.length - 1;
|
|
162
|
+
for (let index = 1; index < lastIndex; index += 1) {
|
|
163
|
+
const segment = compiled.segments.at(index);
|
|
164
|
+
if (!segment) return false;
|
|
165
|
+
const found = findSegment(value, position, segment);
|
|
166
|
+
if (found === -1) return false;
|
|
167
|
+
position = found + segment.literal.length;
|
|
168
|
+
}
|
|
169
|
+
if (lastIndex === 0) return !compiled.anchored || position === value.length;
|
|
170
|
+
const last = compiled.segments.at(lastIndex);
|
|
171
|
+
if (!last) return false;
|
|
172
|
+
if (compiled.anchored) return value.length - last.literal.length >= position && value.endsWith(last.literal);
|
|
173
|
+
return findSegment(value, position, last) !== -1;
|
|
174
|
+
};
|
|
175
|
+
const evaluatePublisherRobots = (groups, productToken, path) => {
|
|
176
|
+
const selected = selectedGroups(groups, productToken);
|
|
177
|
+
const named = selected.some((group) => group.agents.some((agent) => agent !== "*" && productToken.toLowerCase().startsWith(agent)));
|
|
178
|
+
const rules = selected.flatMap(({ rules: groupRules }) => groupRules);
|
|
179
|
+
const normalizedPath = normalizeOctets(path);
|
|
180
|
+
const matches = rules.map((rule) => ({
|
|
181
|
+
...rule,
|
|
182
|
+
compiled: rule.compiled ?? compilePattern(rule.pattern)
|
|
183
|
+
})).filter(({ compiled }) => wildcardMatches(compiled, normalizedPath)).map((rule) => ({
|
|
184
|
+
...rule,
|
|
185
|
+
specificity: rule.compiled.specificity
|
|
186
|
+
}));
|
|
187
|
+
const longest = Math.max(-1, ...matches.map(({ specificity }) => specificity));
|
|
188
|
+
const winners = matches.filter(({ specificity }) => specificity === longest);
|
|
189
|
+
const allowed = longest < 0 || winners.some(({ type }) => type === "allow");
|
|
190
|
+
return {
|
|
191
|
+
allowed,
|
|
192
|
+
agentExcluded: named && rules.some(({ type, pattern }) => type === "disallow" && pattern === "/") && !allowed,
|
|
193
|
+
crawlDelayMs: Math.max(0, ...selected.map(({ crawlDelayMs }) => crawlDelayMs ?? 0))
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
const abortReason = (signal) => signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
197
|
+
const raceAbort = async (work, signal) => {
|
|
198
|
+
if (!signal) return await work;
|
|
199
|
+
const { promise: aborted, reject } = Promise.withResolvers();
|
|
200
|
+
const onAbort = () => reject(abortReason(signal));
|
|
201
|
+
if (signal.aborted) onAbort();
|
|
202
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
203
|
+
try {
|
|
204
|
+
return await Promise.race([work, aborted]);
|
|
205
|
+
} finally {
|
|
206
|
+
signal.removeEventListener("abort", onAbort);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
var PublisherBodyLimitError = class extends TaggedError("publisher_body_limit") {};
|
|
210
|
+
const readBoundedBody = async (response, { maxBytes, signal }) => {
|
|
211
|
+
if (!response.body) return Result.ok(/* @__PURE__ */ new Uint8Array());
|
|
212
|
+
const chunks = [];
|
|
213
|
+
let size = 0;
|
|
214
|
+
let exceeded = false;
|
|
215
|
+
const reader = response.body.getReader();
|
|
216
|
+
try {
|
|
217
|
+
while (true) {
|
|
218
|
+
const next = await raceAbort(reader.read(), signal);
|
|
219
|
+
if (next.done) break;
|
|
220
|
+
size += next.value.byteLength;
|
|
221
|
+
if (size > maxBytes) {
|
|
222
|
+
exceeded = true;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
chunks.push(next.value);
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
try {
|
|
229
|
+
await raceAbort(reader.cancel(), signal);
|
|
230
|
+
} finally {
|
|
231
|
+
reader.releaseLock();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (exceeded) return Result.err(new PublisherBodyLimitError({
|
|
235
|
+
message: "response body exceeds the byte limit",
|
|
236
|
+
url: response.url,
|
|
237
|
+
maxBytes
|
|
238
|
+
}));
|
|
239
|
+
const bytes = new Uint8Array(size);
|
|
240
|
+
let offset = 0;
|
|
241
|
+
for (const chunk of chunks) {
|
|
242
|
+
bytes.set(chunk, offset);
|
|
243
|
+
offset += chunk.byteLength;
|
|
244
|
+
}
|
|
245
|
+
return Result.ok(bytes);
|
|
246
|
+
};
|
|
247
|
+
const readRobotsText = async (response, signal) => {
|
|
248
|
+
const bytes = await readBoundedBody(response, {
|
|
249
|
+
maxBytes: ROBOTS_MAX_BYTES,
|
|
250
|
+
signal
|
|
251
|
+
});
|
|
252
|
+
return Result.isError(bytes) ? Result.err(new RobotsUnavailableError({
|
|
253
|
+
message: "robots.txt exceeds the byte limit",
|
|
254
|
+
url: response.url || "robots.txt",
|
|
255
|
+
origin: response.url ? new URL(response.url).origin : "unknown"
|
|
256
|
+
})) : Result.ok(new TextDecoder().decode(bytes.value));
|
|
257
|
+
};
|
|
258
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
|
|
259
|
+
301,
|
|
260
|
+
302,
|
|
261
|
+
303,
|
|
262
|
+
307,
|
|
263
|
+
308
|
|
264
|
+
]);
|
|
265
|
+
const isRedirect = (response) => REDIRECT_STATUSES.has(response.status);
|
|
266
|
+
const validatePublisherTarget = async (url, validateTarget) => {
|
|
267
|
+
const validated = await validateTarget(url);
|
|
268
|
+
return Result.isError(validated) ? Result.err(new PublisherTargetRefusedError({
|
|
269
|
+
message: "Publisher target was refused",
|
|
270
|
+
url: url.href,
|
|
271
|
+
origin: url.origin,
|
|
272
|
+
cause: validated.error
|
|
273
|
+
})) : validated;
|
|
274
|
+
};
|
|
275
|
+
const redirectedRequestInit = ({ response, currentUrl, nextUrl, requestInit, headers }) => {
|
|
276
|
+
const method = requestInit?.method?.toUpperCase() ?? "GET";
|
|
277
|
+
const dropBody = response.status === 303 && method !== "HEAD" || (response.status === 301 || response.status === 302) && method === "POST";
|
|
278
|
+
if (!dropBody && requestInit?.body instanceof ReadableStream) return Result.err(new PublisherBodyNotReplayableError({
|
|
279
|
+
message: "Publisher redirect requires a replayable request body",
|
|
280
|
+
url: currentUrl.href,
|
|
281
|
+
origin: currentUrl.origin
|
|
282
|
+
}));
|
|
283
|
+
const redirectedHeaders = new Headers(headers);
|
|
284
|
+
if (nextUrl.origin !== currentUrl.origin) for (const name of [
|
|
285
|
+
"Authorization",
|
|
286
|
+
"Cookie",
|
|
287
|
+
"Proxy-Authorization"
|
|
288
|
+
]) redirectedHeaders.delete(name);
|
|
289
|
+
if (dropBody) for (const name of [
|
|
290
|
+
"Content-Type",
|
|
291
|
+
"Content-Length",
|
|
292
|
+
"Content-Encoding",
|
|
293
|
+
"Content-Language",
|
|
294
|
+
"Content-Location"
|
|
295
|
+
]) redirectedHeaders.delete(name);
|
|
296
|
+
return Result.ok({
|
|
297
|
+
...requestInit,
|
|
298
|
+
...dropBody ? {
|
|
299
|
+
body: null,
|
|
300
|
+
method: "GET"
|
|
301
|
+
} : {},
|
|
302
|
+
headers: redirectedHeaders
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
const discardBody = async (response, signal) => Result.tryPromise(async () => raceAbort(response.body?.cancel() ?? Promise.resolve(), signal));
|
|
306
|
+
const loadRobots = async ({ fetcher, validateTarget, origin, now, robotsTtlMs, robotsRetryMs, robotsLoadTimeoutMs }) => {
|
|
307
|
+
const deadline = AbortSignal.timeout(robotsLoadTimeoutMs);
|
|
308
|
+
let target = new URL("/robots.txt", origin);
|
|
309
|
+
let finalResponse;
|
|
310
|
+
let failure;
|
|
311
|
+
for (let redirects = 0;; redirects += 1) {
|
|
312
|
+
const requestTarget = target;
|
|
313
|
+
const checked = await Result.tryPromise(async () => raceAbort(validatePublisherTarget(requestTarget, validateTarget), deadline));
|
|
314
|
+
if (Result.isError(checked)) {
|
|
315
|
+
failure = checked.error.cause;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
const validated = checked.value;
|
|
319
|
+
if (Result.isError(validated)) {
|
|
320
|
+
failure = validated.error;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
const fetched = await Result.tryPromise(async () => raceAbort(fetcher(validated.value, {
|
|
324
|
+
headers: { "User-Agent": PUBLISHER_USER_AGENT },
|
|
325
|
+
redirect: "manual",
|
|
326
|
+
signal: deadline
|
|
327
|
+
}), deadline));
|
|
328
|
+
if (Result.isError(fetched)) {
|
|
329
|
+
failure = fetched.error.cause;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
if (!isRedirect(fetched.value)) {
|
|
333
|
+
finalResponse = fetched.value;
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
const cancelled = await discardBody(fetched.value, deadline);
|
|
337
|
+
if (Result.isError(cancelled)) {
|
|
338
|
+
failure = cancelled.error.cause;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
const location = fetched.value.headers.get("location");
|
|
342
|
+
if (!location || redirects >= MAX_REDIRECTS) {
|
|
343
|
+
failure = new RobotsUnavailableError({
|
|
344
|
+
message: location ? "robots.txt exceeded the redirect limit" : "robots.txt redirect has no location",
|
|
345
|
+
url: requestTarget.href,
|
|
346
|
+
origin
|
|
347
|
+
});
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
if (!URL.canParse(location, requestTarget.href)) {
|
|
351
|
+
failure = new RobotsUnavailableError({
|
|
352
|
+
message: "robots.txt redirect location is invalid",
|
|
353
|
+
url: requestTarget.href,
|
|
354
|
+
origin
|
|
355
|
+
});
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
target = new URL(location, requestTarget);
|
|
359
|
+
}
|
|
360
|
+
if (!finalResponse) {
|
|
361
|
+
if (PublisherTargetRefusedError.is(failure)) return {
|
|
362
|
+
type: "refused",
|
|
363
|
+
state: {
|
|
364
|
+
type: "unavailable",
|
|
365
|
+
cause: failure
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
return {
|
|
369
|
+
type: "cacheable",
|
|
370
|
+
robots: {
|
|
371
|
+
state: {
|
|
372
|
+
type: "unavailable",
|
|
373
|
+
cause: failure
|
|
374
|
+
},
|
|
375
|
+
expiresAt: now() + robotsRetryMs
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (finalResponse.status >= 400) {
|
|
380
|
+
const discarded = await discardBody(finalResponse, deadline);
|
|
381
|
+
if (Result.isError(discarded)) return {
|
|
382
|
+
type: "cacheable",
|
|
383
|
+
robots: {
|
|
384
|
+
state: {
|
|
385
|
+
type: "unavailable",
|
|
386
|
+
cause: discarded.error.cause
|
|
387
|
+
},
|
|
388
|
+
expiresAt: now() + robotsRetryMs
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
if (finalResponse.status >= 400 && finalResponse.status < 500) return {
|
|
393
|
+
type: "cacheable",
|
|
394
|
+
robots: {
|
|
395
|
+
state: { type: "allow-all" },
|
|
396
|
+
expiresAt: now() + robotsTtlMs
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
if (finalResponse.status >= 500) return {
|
|
400
|
+
type: "cacheable",
|
|
401
|
+
robots: {
|
|
402
|
+
state: { type: "unavailable" },
|
|
403
|
+
expiresAt: now() + robotsRetryMs
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
const body = await Result.tryPromise(async () => readRobotsText(finalResponse, deadline));
|
|
407
|
+
if (Result.isError(body)) return {
|
|
408
|
+
type: "cacheable",
|
|
409
|
+
robots: {
|
|
410
|
+
state: {
|
|
411
|
+
type: "unavailable",
|
|
412
|
+
cause: body.error.cause
|
|
413
|
+
},
|
|
414
|
+
expiresAt: now() + robotsRetryMs
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
if (Result.isError(body.value)) return {
|
|
418
|
+
type: "cacheable",
|
|
419
|
+
robots: {
|
|
420
|
+
state: {
|
|
421
|
+
type: "unavailable",
|
|
422
|
+
cause: body.value.error
|
|
423
|
+
},
|
|
424
|
+
expiresAt: now() + robotsRetryMs
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
return {
|
|
428
|
+
type: "cacheable",
|
|
429
|
+
robots: {
|
|
430
|
+
state: {
|
|
431
|
+
type: "rules",
|
|
432
|
+
groups: parseRobots(body.value.value)
|
|
433
|
+
},
|
|
434
|
+
expiresAt: now() + robotsTtlMs
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
};
|
|
438
|
+
const createRobotsCache = (options) => {
|
|
439
|
+
const settled = /* @__PURE__ */ new Map();
|
|
440
|
+
const pending = /* @__PURE__ */ new Map();
|
|
441
|
+
return async (origin) => {
|
|
442
|
+
const cached = settled.get(origin);
|
|
443
|
+
if (cached && cached.expiresAt > options.now()) return cached.state;
|
|
444
|
+
const activeLoad = pending.get(origin);
|
|
445
|
+
if (activeLoad) {
|
|
446
|
+
const activeResult = await activeLoad;
|
|
447
|
+
if (activeResult.type === "cacheable") return activeResult.robots.state;
|
|
448
|
+
}
|
|
449
|
+
const load = loadRobots({
|
|
450
|
+
...options,
|
|
451
|
+
origin
|
|
452
|
+
});
|
|
453
|
+
pending.set(origin, load);
|
|
454
|
+
const loaded = await load;
|
|
455
|
+
if (pending.get(origin) === load) pending.delete(origin);
|
|
456
|
+
if (loaded.type === "refused") return loaded.state;
|
|
457
|
+
settled.set(origin, loaded.robots);
|
|
458
|
+
return loaded.robots.state;
|
|
459
|
+
};
|
|
460
|
+
};
|
|
461
|
+
const createOriginScheduler = (clock) => {
|
|
462
|
+
const queues = /* @__PURE__ */ new Map();
|
|
463
|
+
const nextAllowedAt = /* @__PURE__ */ new Map();
|
|
464
|
+
return async (origin, intervalMs, signal) => {
|
|
465
|
+
const previous = queues.get(origin) ?? Promise.resolve();
|
|
466
|
+
let release;
|
|
467
|
+
const current = new Promise((resolve) => {
|
|
468
|
+
release = resolve;
|
|
469
|
+
});
|
|
470
|
+
queues.set(origin, previous.then(async () => current));
|
|
471
|
+
const acquired = await Result.tryPromise(async () => raceAbort(previous, signal));
|
|
472
|
+
if (Result.isError(acquired)) {
|
|
473
|
+
release();
|
|
474
|
+
const cause = acquired.error.cause;
|
|
475
|
+
return await Promise.reject(cause instanceof Error ? cause : new DOMException("The operation was aborted", "AbortError"));
|
|
476
|
+
}
|
|
477
|
+
const delay = Math.max(0, (nextAllowedAt.get(origin) ?? clock.now()) - clock.now());
|
|
478
|
+
try {
|
|
479
|
+
if (delay > 0) await raceAbort(clock.sleep(delay, signal), signal);
|
|
480
|
+
nextAllowedAt.set(origin, clock.now() + intervalMs);
|
|
481
|
+
return delay;
|
|
482
|
+
} finally {
|
|
483
|
+
release();
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
};
|
|
487
|
+
const abortBoundRuntimeBrand = Symbol("abort-bound-publisher-runtime");
|
|
488
|
+
const bindPublisherRuntimeToAbort = (runtime, signal) => {
|
|
489
|
+
const onTelemetry = runtime.onTelemetry;
|
|
490
|
+
const bound = {
|
|
491
|
+
fetcher: async (...args) => await raceAbort(runtime.fetcher(...args), signal),
|
|
492
|
+
validateTarget: async (...args) => await raceAbort(runtime.validateTarget(...args), signal),
|
|
493
|
+
robotsFor: async (...args) => await raceAbort(runtime.robotsFor(...args), signal),
|
|
494
|
+
reserve: async (...args) => await raceAbort(runtime.reserve(...args), signal),
|
|
495
|
+
defaultMinimumIntervalMs: runtime.defaultMinimumIntervalMs,
|
|
496
|
+
onTelemetry: onTelemetry ? async (...args) => await raceAbort(onTelemetry(...args), signal) : void 0
|
|
497
|
+
};
|
|
498
|
+
return Object.assign(bound, { [abortBoundRuntimeBrand]: true });
|
|
499
|
+
};
|
|
500
|
+
const enforcePublisherPolicy = async (state, options, runtime) => {
|
|
501
|
+
const signal = state.requestInit?.signal ?? void 0;
|
|
502
|
+
const loaded = await Result.tryPromise(async () => runtime.robotsFor(state.url.origin));
|
|
503
|
+
if (Result.isError(loaded)) return Result.err(new PublisherRequestAbortedError({
|
|
504
|
+
message: "Publisher request was aborted while loading robots.txt",
|
|
505
|
+
url: state.url.href,
|
|
506
|
+
origin: state.url.origin,
|
|
507
|
+
cause: loaded.error.cause
|
|
508
|
+
}));
|
|
509
|
+
const robots = loaded.value;
|
|
510
|
+
if (robots.type === "unavailable" && PublisherTargetRefusedError.is(robots.cause)) return Result.err(robots.cause);
|
|
511
|
+
const evaluated = publisherPolicyFor(robots, state.url, runtime.defaultMinimumIntervalMs);
|
|
512
|
+
state.decision = evaluated.decision ?? state.decision;
|
|
513
|
+
const intervalMs = Math.min(evaluated.intervalMs, MAX_CRAWL_DELAY_MS);
|
|
514
|
+
const refusal = evaluated.refusal;
|
|
515
|
+
if (!refusal) return Result.ok(intervalMs);
|
|
516
|
+
const policy = state.url.origin === state.requestedOrigin ? options.policy : void 0;
|
|
517
|
+
if (policy?.type !== "licensed") return Result.err(refusal);
|
|
518
|
+
if (runtime.onTelemetry === void 0) return Result.err(new PublisherBypassUnauditedError({
|
|
519
|
+
message: "A licensed bypass needs an onTelemetry sink to record its evidence",
|
|
520
|
+
url: state.url.href,
|
|
521
|
+
origin: state.url.origin,
|
|
522
|
+
cause: refusal
|
|
523
|
+
}));
|
|
524
|
+
const sink = runtime.onTelemetry;
|
|
525
|
+
const recorded = await Result.tryPromise(async () => {
|
|
526
|
+
await sink({
|
|
527
|
+
event: "publisher_fetch_policy_bypass",
|
|
528
|
+
url: state.url.href,
|
|
529
|
+
origin: state.url.origin,
|
|
530
|
+
refusal,
|
|
531
|
+
policy,
|
|
532
|
+
delayMs: intervalMs
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
if (Result.isError(recorded)) {
|
|
536
|
+
if (signal?.aborted) return Result.err(new PublisherRequestAbortedError({
|
|
537
|
+
message: "Publisher request was aborted while recording its licensed bypass",
|
|
538
|
+
url: state.url.href,
|
|
539
|
+
origin: state.url.origin,
|
|
540
|
+
cause: recorded.error.cause
|
|
541
|
+
}));
|
|
542
|
+
return Result.err(new PublisherBypassUnauditedError({
|
|
543
|
+
message: "Recording the licensed bypass failed",
|
|
544
|
+
url: state.url.href,
|
|
545
|
+
origin: state.url.origin,
|
|
546
|
+
cause: recorded.error.cause
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
549
|
+
state.decision = "licensed_bypass";
|
|
550
|
+
return Result.ok(intervalMs);
|
|
551
|
+
};
|
|
552
|
+
const redirectLimitResult = (state) => {
|
|
553
|
+
const refusal = new PublisherRedirectLimitError({
|
|
554
|
+
message: "publisher response exceeded the redirect limit",
|
|
555
|
+
url: state.url.href,
|
|
556
|
+
origin: state.url.origin
|
|
557
|
+
});
|
|
558
|
+
return Result.err(refusal);
|
|
559
|
+
};
|
|
560
|
+
const walkPublisherRedirects = async (input, options, runtime) => {
|
|
561
|
+
const url = new URL(input);
|
|
562
|
+
const state = {
|
|
563
|
+
requestedOrigin: url.origin,
|
|
564
|
+
url,
|
|
565
|
+
requestInit: options.init,
|
|
566
|
+
decision: "allowed",
|
|
567
|
+
delayAppliedMs: 0
|
|
568
|
+
};
|
|
569
|
+
for (let redirects = 0;; redirects += 1) {
|
|
570
|
+
const signal = state.requestInit?.signal ?? void 0;
|
|
571
|
+
const checked = await Result.tryPromise(async () => validatePublisherTarget(state.url, runtime.validateTarget));
|
|
572
|
+
if (Result.isError(checked)) return Result.err(new PublisherRequestAbortedError({
|
|
573
|
+
message: "Publisher request was aborted while validating its target",
|
|
574
|
+
url: state.url.href,
|
|
575
|
+
origin: state.url.origin,
|
|
576
|
+
cause: checked.error.cause
|
|
577
|
+
}));
|
|
578
|
+
const validated = checked.value;
|
|
579
|
+
if (Result.isError(validated)) return validated;
|
|
580
|
+
state.url = validated.value.url;
|
|
581
|
+
const interval = await enforcePublisherPolicy(state, options, runtime);
|
|
582
|
+
if (Result.isError(interval)) return interval;
|
|
583
|
+
const reserved = await Result.tryPromise(async () => runtime.reserve(state.url.origin, interval.value, state.requestInit?.signal ?? void 0));
|
|
584
|
+
if (Result.isError(reserved)) return Result.err(new PublisherRequestAbortedError({
|
|
585
|
+
message: "Publisher request was aborted while waiting",
|
|
586
|
+
url: state.url.href,
|
|
587
|
+
origin: state.url.origin,
|
|
588
|
+
cause: reserved.error.cause
|
|
589
|
+
}));
|
|
590
|
+
state.delayAppliedMs += reserved.value;
|
|
591
|
+
const headers = new Headers(state.requestInit?.headers);
|
|
592
|
+
headers.set("User-Agent", PUBLISHER_USER_AGENT);
|
|
593
|
+
const fetched = await Result.tryPromise(async () => runtime.fetcher(validated.value, {
|
|
594
|
+
...state.requestInit,
|
|
595
|
+
headers,
|
|
596
|
+
redirect: "manual"
|
|
597
|
+
}));
|
|
598
|
+
if (Result.isError(fetched)) {
|
|
599
|
+
if (!signal?.aborted) {
|
|
600
|
+
const cause = fetched.error.cause;
|
|
601
|
+
return await Promise.reject(cause instanceof Error ? cause : new Error("Publisher fetch failed", { cause }));
|
|
602
|
+
}
|
|
603
|
+
return Result.err(new PublisherRequestAbortedError({
|
|
604
|
+
message: "Publisher request was aborted while fetching the resource",
|
|
605
|
+
url: state.url.href,
|
|
606
|
+
origin: state.url.origin,
|
|
607
|
+
cause: fetched.error.cause
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
const response = fetched.value;
|
|
611
|
+
const location = response.headers.get("location");
|
|
612
|
+
if (!isRedirect(response) || !location) return Result.ok({
|
|
613
|
+
response,
|
|
614
|
+
robotsDecision: state.decision,
|
|
615
|
+
delayAppliedMs: state.delayAppliedMs
|
|
616
|
+
});
|
|
617
|
+
const discarded = await discardBody(response, state.requestInit?.signal ?? void 0);
|
|
618
|
+
if (Result.isError(discarded)) return Result.err(new PublisherRequestAbortedError({
|
|
619
|
+
message: "Publisher request was aborted while discarding a redirect",
|
|
620
|
+
url: state.url.href,
|
|
621
|
+
origin: state.url.origin,
|
|
622
|
+
cause: discarded.error.cause
|
|
623
|
+
}));
|
|
624
|
+
if (redirects >= MAX_REDIRECTS) return redirectLimitResult(state);
|
|
625
|
+
if (!URL.canParse(location, state.url.href)) return Result.err(new PublisherTargetRefusedError({
|
|
626
|
+
message: "publisher redirect location is invalid",
|
|
627
|
+
url: state.url.href,
|
|
628
|
+
origin: state.url.origin,
|
|
629
|
+
cause: location
|
|
630
|
+
}));
|
|
631
|
+
const nextUrl = new URL(location, state.url);
|
|
632
|
+
const redirected = redirectedRequestInit({
|
|
633
|
+
response,
|
|
634
|
+
currentUrl: state.url,
|
|
635
|
+
nextUrl,
|
|
636
|
+
requestInit: state.requestInit,
|
|
637
|
+
headers
|
|
638
|
+
});
|
|
639
|
+
if (Result.isError(redirected)) return redirected;
|
|
640
|
+
state.requestInit = redirected.value;
|
|
641
|
+
state.url = nextUrl;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
const createPolitePublisherFetch = ({ validateTarget, fetcher, clock = {
|
|
645
|
+
now: () => performance.now(),
|
|
646
|
+
sleep: async (ms, signal) => {
|
|
647
|
+
signal?.throwIfAborted();
|
|
648
|
+
await raceAbort(Bun.sleep(ms), signal);
|
|
649
|
+
signal?.throwIfAborted();
|
|
650
|
+
}
|
|
651
|
+
}, robotsTtlMs = ROBOTS_TTL_MS, robotsRetryMs = ROBOTS_RETRY_MS, robotsLoadTimeoutMs = ROBOTS_LOAD_TIMEOUT_MS, defaultMinimumIntervalMs = DEFAULT_MINIMUM_INTERVAL_MS, onTelemetry }) => {
|
|
652
|
+
const runtime = {
|
|
653
|
+
fetcher,
|
|
654
|
+
validateTarget,
|
|
655
|
+
robotsFor: createRobotsCache({
|
|
656
|
+
fetcher,
|
|
657
|
+
validateTarget,
|
|
658
|
+
now: clock.now,
|
|
659
|
+
robotsTtlMs,
|
|
660
|
+
robotsRetryMs,
|
|
661
|
+
robotsLoadTimeoutMs
|
|
662
|
+
}),
|
|
663
|
+
reserve: createOriginScheduler(clock),
|
|
664
|
+
defaultMinimumIntervalMs,
|
|
665
|
+
onTelemetry: onTelemetry ? async (event) => await onTelemetry(event) : void 0
|
|
666
|
+
};
|
|
667
|
+
return async (input, options = {}) => await walkPublisherRedirects(input, options, bindPublisherRuntimeToAbort(runtime, options.init?.signal ?? void 0));
|
|
668
|
+
};
|
|
669
|
+
//#endregion
|
|
670
|
+
export { AgentExcludedError, CrawlDelayUnsupportedError, PublisherBodyLimitError, PublisherBodyNotReplayableError, PublisherBypassUnauditedError, PublisherRedirectLimitError, PublisherRequestAbortedError, PublisherTargetRefusedError, RobotsDisallowedError, RobotsUnavailableError, createPolitePublisherFetch, evaluatePublisherRobots, readBoundedBody };
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stll/fetch",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fetch with a mandatory timeout (headers or idle body window), connection-failure classification, and a robots-aware polite publisher fetch",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"abort",
|
|
7
|
+
"fetch",
|
|
8
|
+
"http",
|
|
9
|
+
"robots",
|
|
10
|
+
"timeout"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/stella/stella/tree/main/packages/fetch",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/stella/stella/issues"
|
|
15
|
+
},
|
|
16
|
+
"license": "Apache-2.0",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/stella/stella.git",
|
|
20
|
+
"directory": "packages/fetch"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./publisher": {
|
|
34
|
+
"types": "./dist/publisher.d.ts",
|
|
35
|
+
"import": "./dist/publisher.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"clean": "git clean -xdf dist .cache .turbo node_modules",
|
|
43
|
+
"build": "tsdown",
|
|
44
|
+
"pack:dry-run": "bun pm pack --dry-run",
|
|
45
|
+
"test": "bun test src",
|
|
46
|
+
"typecheck": "bun check --no-pretty --all --project=tsconfig.json",
|
|
47
|
+
"lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --type-aware --type-check packages/fetch",
|
|
48
|
+
"lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/fetch",
|
|
49
|
+
"test:property": "bun test --preload @stll/property-testing/preload $(grep -rlE 'fc\\.assert|assertProperty' src --include='*.test.ts')",
|
|
50
|
+
"prepack": "bun run build"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"better-result": "3.0.1"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@stll/property-testing": "0.0.0",
|
|
57
|
+
"@stll/typescript-config": "0.0.0",
|
|
58
|
+
"bun-types": "1.4.4",
|
|
59
|
+
"fast-check": "^4.10.2",
|
|
60
|
+
"tsdown": "0.23.0"
|
|
61
|
+
},
|
|
62
|
+
"main": "./dist/index.js",
|
|
63
|
+
"types": "./dist/index.d.ts"
|
|
6
64
|
}
|