@zeldrisho/pi-web-fetch 0.10.2 → 0.11.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.
@@ -1,4 +1,4 @@
1
- import type { CompleteDocument } from "./content";
1
+ import type { CompleteDocument } from "./content.ts";
2
2
 
3
3
  /** Removes a URL fragment before acquisition so fragment variants share cache entries. */
4
4
  export function urlWithoutFragment(rawUrl: string): string {
package/src/index.ts CHANGED
@@ -7,7 +7,17 @@ import { executeWebFetch, resetLlmsNoticeOrigins } from "./service";
7
7
 
8
8
  export { ExpiringLruCache } from "./cache";
9
9
 
10
- export type { FetchResult } from "./content";
10
+ export {
11
+ hasAnubisMarkers,
12
+ parseAnubisChallenge,
13
+ solveAnubisChallenge,
14
+ type AnubisChallenge,
15
+ type AnubisSolveContext,
16
+ } from "./challenges/anubis.ts";
17
+
18
+ export { CookieJar } from "./network/cookies.ts";
19
+
20
+ export type { FetchResult } from "./document/content.ts";
11
21
 
12
22
  export {
13
23
  diagnoseExtraction,
@@ -15,13 +25,21 @@ export {
15
25
  type ExtractedLink,
16
26
  type ExtractedLinks,
17
27
  type ExtractionDiagnostics,
18
- } from "./evidence";
28
+ } from "./document/evidence.ts";
19
29
 
20
- export { fetchRemoteContent, npmRegistryFallbackUrl, type FetchRemoteDependencies } from "./fetch";
30
+ export {
31
+ fetchRemoteContent,
32
+ npmRegistryFallbackUrl,
33
+ type FetchRemoteDependencies,
34
+ } from "./acquisition/fetch.ts";
21
35
 
22
- export { focusMarkdown, type FocusDetails, type FocusResult } from "./focus";
36
+ export { focusMarkdown, type FocusDetails, type FocusResult } from "./document/focus.ts";
23
37
 
24
- export { createDocumentOutline, type DocumentOutline, type OutlineHeading } from "./outline";
38
+ export {
39
+ createDocumentOutline,
40
+ type DocumentOutline,
41
+ type OutlineHeading,
42
+ } from "./document/outline.ts";
25
43
 
26
44
  export { redactUrlForDisplay } from "./redact";
27
45
 
@@ -39,7 +57,7 @@ export {
39
57
  requestPinned,
40
58
  validateRemoteUrl,
41
59
  type ValidatedTarget,
42
- } from "./network";
60
+ } from "./network/network.ts";
43
61
 
44
62
  export const webFetchParameters = Type.Object({
45
63
  url: Type.String(),
@@ -0,0 +1,260 @@
1
+ import type { IncomingHttpHeaders, IncomingMessage } from "node:http";
2
+
3
+ /** Maximum number of cookies retained by one jar. */
4
+ const MAX_COOKIES = 128;
5
+
6
+ /** Maximum accepted cookie value length in characters. */
7
+ const MAX_COOKIE_VALUE_CHARACTERS = 4_096;
8
+
9
+ /** Maximum accepted cookie name length in characters. */
10
+ const MAX_COOKIE_NAME_CHARACTERS = 256;
11
+
12
+ /** RFC 6265 cookie-name token characters. */
13
+ const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
14
+
15
+ /** Returns whether a value contains a control character that cookie headers must not carry. */
16
+ function hasInvalidCookieCharacter(value: string): boolean {
17
+ for (let index = 0; index < value.length; index += 1) {
18
+ const code = value.charCodeAt(index);
19
+
20
+ if (code <= 0x1f || code === 0x7f) return true;
21
+ }
22
+
23
+ return false;
24
+ }
25
+
26
+ interface StoredCookie {
27
+ name: string;
28
+ value: string;
29
+ origin: string;
30
+ path: string;
31
+ secure: boolean;
32
+ createdAt: number;
33
+ expiresAt?: number;
34
+ }
35
+
36
+ interface ParsedCookie {
37
+ name: string;
38
+ value: string;
39
+ path: string;
40
+ secure: boolean;
41
+ expiresAt?: number;
42
+ }
43
+
44
+ /** Returns the default cookie path for a request path, per RFC 6265 section 5.1.4. */
45
+ function defaultCookiePath(pathname: string): string {
46
+ if (!pathname.startsWith("/")) return "/";
47
+
48
+ const index = pathname.lastIndexOf("/");
49
+
50
+ return index <= 0 ? "/" : pathname.slice(0, index);
51
+ }
52
+
53
+ /** Returns whether a request path is covered by a cookie path. */
54
+ function pathMatches(pathname: string, cookiePath: string): boolean {
55
+ if (pathname === cookiePath) return true;
56
+
57
+ if (!pathname.startsWith(cookiePath)) return false;
58
+
59
+ return cookiePath.endsWith("/") || pathname[cookiePath.length] === "/";
60
+ }
61
+
62
+ /** Returns whether a response host may set a cookie for the supplied domain attribute. */
63
+ function domainMatches(hostname: string, domain: string): boolean {
64
+ return hostname === domain || hostname.endsWith(`.${domain}`);
65
+ }
66
+
67
+ /** Normalizes a Domain attribute value, rejecting values that cannot match the response host. */
68
+ function normalizeDomain(raw: string, hostname: string): string | undefined {
69
+ const domain = raw.trim().toLowerCase().replace(/^\./, "");
70
+
71
+ if (!domain || hasInvalidCookieCharacter(domain) || domain.includes("..")) return undefined;
72
+
73
+ if (!domainMatches(hostname, domain)) return undefined;
74
+
75
+ return domain;
76
+ }
77
+
78
+ /** Parses one Set-Cookie header into the subset of attributes this jar uses. */
79
+ function parseSetCookie(raw: string, url: URL, now: number): ParsedCookie | undefined {
80
+ const parts = raw.split(";");
81
+ const pair = parts[0]?.trim() ?? "";
82
+ const separator = pair.indexOf("=");
83
+
84
+ if (separator <= 0) return undefined;
85
+
86
+ const name = pair.slice(0, separator).trim();
87
+ const value = pair.slice(separator + 1).trim();
88
+
89
+ if (
90
+ name.length === 0 ||
91
+ name.length > MAX_COOKIE_NAME_CHARACTERS ||
92
+ !COOKIE_NAME.test(name) ||
93
+ value.length > MAX_COOKIE_VALUE_CHARACTERS ||
94
+ hasInvalidCookieCharacter(value)
95
+ ) {
96
+ return undefined;
97
+ }
98
+
99
+ let path = defaultCookiePath(url.pathname || "/");
100
+ let secure = false;
101
+ let maxAge: number | undefined;
102
+ let expires: number | undefined;
103
+
104
+ for (let index = 1; index < parts.length; index += 1) {
105
+ const attribute = parts[index]?.trim() ?? "";
106
+ const equals = attribute.indexOf("=");
107
+
108
+ const attributeName = (equals === -1 ? attribute : attribute.slice(0, equals))
109
+ .trim()
110
+ .toLowerCase();
111
+
112
+ const attributeValue = equals === -1 ? "" : attribute.slice(equals + 1).trim();
113
+
114
+ if (attributeName === "secure") {
115
+ secure = true;
116
+ continue;
117
+ }
118
+
119
+ if (attributeName === "path" && attributeValue.startsWith("/")) {
120
+ if (!hasInvalidCookieCharacter(attributeValue)) path = attributeValue;
121
+
122
+ continue;
123
+ }
124
+
125
+ if (attributeName === "domain") {
126
+ if (normalizeDomain(attributeValue, url.hostname) === undefined) return undefined;
127
+
128
+ continue;
129
+ }
130
+
131
+ if (attributeName === "max-age") {
132
+ const seconds = Number(attributeValue);
133
+
134
+ if (Number.isInteger(seconds)) maxAge = seconds;
135
+
136
+ continue;
137
+ }
138
+
139
+ if (attributeName === "expires") {
140
+ const parsed = Date.parse(attributeValue);
141
+
142
+ if (Number.isFinite(parsed)) expires = parsed;
143
+ }
144
+ }
145
+
146
+ const expiresAt =
147
+ maxAge === undefined
148
+ ? expires
149
+ : maxAge <= 0
150
+ ? now - 1
151
+ : now + Math.min(maxAge, 31_536_000) * 1_000;
152
+
153
+ return { name, value, path, secure, expiresAt };
154
+ }
155
+
156
+ /** Returns every Set-Cookie value from a response without relying on comma splitting. */
157
+ function setCookieValues(headers: IncomingHttpHeaders): string[] {
158
+ const value = headers["set-cookie"];
159
+
160
+ if (Array.isArray(value)) return value;
161
+
162
+ return value === undefined ? [] : [value];
163
+ }
164
+
165
+ /**
166
+ * A bounded, origin-scoped cookie jar used for one web fetch acquisition.
167
+ *
168
+ * Cookies are accepted only for the origin that supplied them and are never
169
+ * persisted, logged, or sent to another origin. Domain attributes must match the
170
+ * response host; the jar still scopes the stored cookie to that exact origin,
171
+ * which is stricter than a browser and appropriate for challenge handshakes.
172
+ */
173
+ export class CookieJar {
174
+ readonly #cookies = new Map<string, StoredCookie>();
175
+
176
+ /** Number of unexpired cookies currently stored. */
177
+ get size(): number {
178
+ return this.#cookies.size;
179
+ }
180
+
181
+ /**
182
+ * Stores cookies supplied by a response for the given request URL.
183
+ *
184
+ * @param response - The HTTP response whose Set-Cookie headers should be stored.
185
+ * @param url - The final URL that produced the response.
186
+ */
187
+ store(response: Pick<IncomingMessage, "headers">, url: URL): void {
188
+ const now = Date.now();
189
+
190
+ for (const raw of setCookieValues(response.headers)) {
191
+ const parsed = parseSetCookie(raw, url, now);
192
+
193
+ if (!parsed) continue;
194
+
195
+ const key = `${url.origin}\0${parsed.name}\0${parsed.path}`;
196
+ this.#cookies.delete(key);
197
+
198
+ if (parsed.expiresAt !== undefined && parsed.expiresAt <= now) continue;
199
+
200
+ this.#cookies.set(key, {
201
+ name: parsed.name,
202
+ value: parsed.value,
203
+ origin: url.origin,
204
+ path: parsed.path,
205
+ secure: parsed.secure,
206
+ createdAt: now,
207
+ expiresAt: parsed.expiresAt,
208
+ });
209
+ }
210
+
211
+ this.#evict();
212
+ }
213
+
214
+ /**
215
+ * Returns a Cookie header value for a URL, excluding expired, insecure, and
216
+ * cross-origin cookies.
217
+ *
218
+ * @param url - The request URL.
219
+ * @returns A serialized Cookie header value, or undefined when no cookie matches.
220
+ */
221
+ headerFor(url: URL): string | undefined {
222
+ const now = Date.now();
223
+ const matches: StoredCookie[] = [];
224
+
225
+ for (const [key, cookie] of this.#cookies) {
226
+ if (cookie.expiresAt !== undefined && cookie.expiresAt <= now) {
227
+ this.#cookies.delete(key);
228
+ continue;
229
+ }
230
+
231
+ if (cookie.origin !== url.origin) continue;
232
+
233
+ if (cookie.secure && url.protocol !== "https:") continue;
234
+
235
+ if (!pathMatches(url.pathname || "/", cookie.path)) continue;
236
+
237
+ matches.push(cookie);
238
+ }
239
+
240
+ if (matches.length === 0) return undefined;
241
+
242
+ matches.sort((left, right) => {
243
+ const pathOrder = right.path.length - left.path.length;
244
+
245
+ return pathOrder !== 0 ? pathOrder : left.createdAt - right.createdAt;
246
+ });
247
+
248
+ return matches.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
249
+ }
250
+
251
+ /** Drops oldest cookies until the jar is within its entry bound. */
252
+ #evict(): void {
253
+ while (this.#cookies.size > MAX_COOKIES) {
254
+ const oldest = this.#cookies.keys().next().value;
255
+
256
+ if (oldest === undefined) return;
257
+ this.#cookies.delete(oldest);
258
+ }
259
+ }
260
+ }
@@ -1,7 +1,8 @@
1
1
  import type { IncomingMessage } from "node:http";
2
- import { awaitWithAbort } from "./abort";
3
- import { validateRemoteUrl, type ValidatedTarget } from "./network-policy";
4
- import { requestPinned, responseHeader } from "./network-transport";
2
+ import { awaitWithAbort } from "../abort";
3
+ import type { CookieJar } from "./cookies.ts";
4
+ import { validateRemoteUrl, type ValidatedTarget } from "./network-policy.ts";
5
+ import { requestPinned, responseHeader } from "./network-transport.ts";
5
6
 
6
7
  /** Maximum number of HTTP redirects to follow before aborting. */
7
8
  export const FETCH_MAX_REDIRECTS = 5;
@@ -31,6 +32,8 @@ export interface RedirectDependencies {
31
32
  ) => Promise<IncomingMessage>;
32
33
  random?: () => number;
33
34
  sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
35
+ /** Origin-scoped cookies for the current acquisition, including challenge handshakes. */
36
+ cookies?: CookieJar;
34
37
  }
35
38
 
36
39
  /** Creates the consistent abort error used by redirect and retry coordination. */
@@ -140,13 +143,18 @@ async function coordinatedRequest(
140
143
  for (let attempt = 0; ; attempt += 1) {
141
144
  const release = await acquireOrigin(target.url.origin, signal);
142
145
  let response: IncomingMessage;
146
+ const cookieHeader = dependencies.cookies?.headerFor(target.url);
147
+ const requestHeaders = cookieHeader ? { Cookie: cookieHeader, ...headers } : headers;
143
148
 
144
149
  try {
145
- response = await request(target, signal, headers);
150
+ response = await request(target, signal, requestHeaders);
146
151
  } finally {
147
152
  release();
148
153
  }
149
154
 
155
+ // Record cookies before any retry destroy so challenge handshakes keep state.
156
+ dependencies.cookies?.store(response, target.url);
157
+
150
158
  const status = response.statusCode ?? 0;
151
159
 
152
160
  if (![429, 503].includes(status) || attempt >= MAX_RETRIES) return response;
@@ -1,8 +1,9 @@
1
1
  import { request as httpRequest, type IncomingMessage } from "node:http";
2
2
  import { request as httpsRequest } from "node:https";
3
3
  import { isIP, type LookupFunction } from "node:net";
4
+ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
4
5
  import { formatSize } from "@earendil-works/pi-coding-agent";
5
- import type { ValidatedTarget } from "./network-policy";
6
+ import type { ValidatedTarget } from "./network-policy.ts";
6
7
 
7
8
  /** Maximum size in bytes for raw HTTP response bodies. */
8
9
  export const FETCH_MAX_BYTES = 5 * 1_024 * 1_024;
@@ -285,6 +286,41 @@ export async function readResponseBytes(
285
286
  * @param contentTypeHeader - Value of the Content-Type response header
286
287
  * @returns Decoded response string
287
288
  */
289
+ /** Decompresses supported HTTP content codings while bounding expansion. */
290
+ export function decompressResponse(
291
+ bytes: Uint8Array,
292
+ contentEncoding: string | undefined,
293
+ ): Uint8Array {
294
+ const encodings = (contentEncoding ?? "identity")
295
+ .split(",")
296
+ .map((encoding) => encoding.trim().toLowerCase())
297
+ .filter((encoding) => encoding && encoding !== "identity");
298
+
299
+ for (const encoding of encodings.reverse()) {
300
+ try {
301
+ const options = { maxOutputLength: FETCH_MAX_BYTES };
302
+
303
+ if (encoding === "gzip" || encoding === "x-gzip") bytes = gunzipSync(bytes, options);
304
+ else if (encoding === "deflate") bytes = inflateSync(bytes, options);
305
+ else if (encoding === "br") bytes = brotliDecompressSync(bytes, options);
306
+ else throw new Error(`unsupported content encoding: ${encoding}`);
307
+ } catch (error) {
308
+ if (error instanceof Error && error.message.startsWith("unsupported content encoding:"))
309
+ throw error;
310
+
311
+ if (error instanceof Error && "code" in error && error.code === "ERR_BUFFER_TOO_LARGE") {
312
+ throw new Error(
313
+ `web_fetch decompressed response exceeds the ${formatSize(FETCH_MAX_BYTES)} limit.`,
314
+ );
315
+ }
316
+
317
+ throw new Error(`web_fetch could not decode ${encoding}-encoded response.`);
318
+ }
319
+ }
320
+
321
+ return bytes;
322
+ }
323
+
288
324
  export function decodeResponse(bytes: Uint8Array, contentTypeHeader: string): string {
289
325
  const charset = contentTypeHeader.match(/(?:^|;)\s*charset\s*=\s*["']?([^;"'\s]+)/i)?.[1];
290
326
 
@@ -4,12 +4,13 @@ export {
4
4
  validateRemoteUrl,
5
5
  type ResolveAddresses,
6
6
  type ValidatedTarget,
7
- } from "./network-policy";
7
+ } from "./network-policy.ts";
8
8
 
9
9
  export {
10
10
  decodeResponse,
11
+ decompressResponse,
11
12
  FETCH_MAX_BYTES,
12
13
  readResponseBytes,
13
14
  requestPinned,
14
15
  responseHeader,
15
- } from "./network-transport";
16
+ } from "./network-transport.ts";
package/src/service.ts CHANGED
@@ -8,13 +8,13 @@ import {
8
8
  truncateHead,
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import { ExpiringLruCache, stableKeyHash, type CachePersistence } from "./cache";
11
- import { sliceCompleteDocument, type CompleteDocument } from "./content";
12
- import { revalidateCompleteDocument, type FetchRemoteDependencies } from "./fetch";
13
- import { fetchDocumentWithLlmsTxtSupport } from "./llms";
14
- import { focusMarkdown, type FocusDetails } from "./focus";
15
- import { resolveFragmentOffset, urlWithoutFragment } from "./fragments";
11
+ import { sliceCompleteDocument, type CompleteDocument } from "./document/content.ts";
12
+ import { revalidateCompleteDocument, type FetchRemoteDependencies } from "./acquisition/fetch.ts";
13
+ import { fetchDocumentWithLlmsTxtSupport } from "./acquisition/llms.ts";
14
+ import { focusMarkdown, type FocusDetails } from "./document/focus.ts";
15
+ import { resolveFragmentOffset, urlWithoutFragment } from "./document/fragments.ts";
16
16
  import { InflightCoalescer } from "./inflight";
17
- import { createDocumentOutline, type DocumentOutline } from "./outline";
17
+ import { createDocumentOutline, type DocumentOutline } from "./document/outline.ts";
18
18
  import { redactUrlForDisplay } from "./redact";
19
19
  import {
20
20
  FETCH_DEFAULT_MAX_CHARACTERS,
@@ -129,7 +129,7 @@ export function classifyConfidence(
129
129
  return markdownLength >= 200 ? "medium" : "low";
130
130
  }
131
131
 
132
- export { buildLlmsTxtCandidateUrls } from "./llms";
132
+ export { buildLlmsTxtCandidateUrls } from "./acquisition/llms.ts";
133
133
 
134
134
  /** Resolves the private, cross-session cache directory for a web tool. */
135
135
  function resolveCacheDirectory(name: string): string {
@@ -0,0 +1,164 @@
1
+ import { createHash } from "node:crypto";
2
+ /** Maximum length in bytes of the server-provided random challenge data. */
3
+ export const ANUBIS_MAX_RANDOM_DATA_BYTES = 4_096;
4
+ /** Maximum size in bytes of a server-provided WebAssembly challenge module. */
5
+ export const ANUBIS_MAX_WASM_BYTES = 4 * 1_024 * 1_024;
6
+ /** The host function Anubis baseline WebAssembly modules import. */
7
+ const ANUBIS_WASM_IMPORT = { module: "anubis", name: "anubis_update_nonce" };
8
+ /** Exports required from an Anubis baseline WebAssembly module. */
9
+ const ANUBIS_WASM_EXPORTS = [
10
+ "data_ptr",
11
+ "set_data_length",
12
+ "anubis_work",
13
+ "result_hash_ptr",
14
+ "result_hash_size",
15
+ ];
16
+ /** Decodes a lowercase or uppercase hexadecimal string, rejecting odd or non-hex input. */
17
+ function decodeHex(value) {
18
+ if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(value))
19
+ return undefined;
20
+ const bytes = new Uint8Array(value.length / 2);
21
+ for (let index = 0; index < bytes.length; index += 1) {
22
+ bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
23
+ }
24
+ return bytes;
25
+ }
26
+ /** Returns a defensive snapshot of a WebAssembly memory range. */
27
+ function readMemory(memory, pointer, size) {
28
+ const offset = pointer >>> 0;
29
+ if (size < 0 || offset + size > memory.buffer.byteLength)
30
+ throw new Error("Anubis challenge module returned an out-of-bounds pointer.");
31
+ return new Uint8Array(memory.buffer, offset, size).slice();
32
+ }
33
+ /** Copies bytes into a validated WebAssembly memory range. */
34
+ function writeMemory(memory, pointer, bytes) {
35
+ const offset = pointer >>> 0;
36
+ if (offset + bytes.byteLength > memory.buffer.byteLength)
37
+ throw new Error("Anubis challenge module returned an out-of-bounds pointer.");
38
+ new Uint8Array(memory.buffer, offset, bytes.byteLength).set(bytes);
39
+ }
40
+ /** Returns the number of leading zero bits in a byte sequence. */
41
+ function leadingZeroBits(bytes) {
42
+ let bits = 0;
43
+ for (const byte of bytes) {
44
+ if (byte === 0) {
45
+ bits += 8;
46
+ continue;
47
+ }
48
+ bits += Math.clz32(byte) - 24;
49
+ break;
50
+ }
51
+ return bits;
52
+ }
53
+ /** Narrows a WebAssembly export value to a numeric function. */
54
+ function isWasmFunction(value) {
55
+ return typeof value === "function";
56
+ }
57
+ /** Reads a required numeric function export from an instantiated module. */
58
+ function requireFunction(exports, name) {
59
+ const value = exports[name];
60
+ if (!isWasmFunction(value))
61
+ throw new Error(`Anubis challenge module is missing ${name}.`);
62
+ return value;
63
+ }
64
+ /**
65
+ * Verifies that a module only imports the Anubis host function and exports the
66
+ * interface the baseline solver needs, so a hostile server cannot pass a module
67
+ * that reaches outside its sandbox.
68
+ */
69
+ function assertSupportedModule(module) {
70
+ const imports = WebAssembly.Module.imports(module);
71
+ if (imports.length !== 1 ||
72
+ imports[0].module !== ANUBIS_WASM_IMPORT.module ||
73
+ imports[0].name !== ANUBIS_WASM_IMPORT.name ||
74
+ imports[0].kind !== "function") {
75
+ throw new Error("Anubis challenge module requested unsupported imports.");
76
+ }
77
+ const exported = new Set(WebAssembly.Module.exports(module)
78
+ .filter((entry) => entry.kind === "function")
79
+ .map((entry) => entry.name));
80
+ for (const name of ANUBIS_WASM_EXPORTS) {
81
+ if (!exported.has(name))
82
+ throw new Error(`Anubis challenge module is missing ${name}.`);
83
+ }
84
+ }
85
+ /**
86
+ * Solves a `fast` or `slow` challenge by searching for a nonce whose
87
+ * `sha256(randomData + nonce)` has `difficulty` leading zero hexadecimal digits.
88
+ *
89
+ * @param randomData - The hex challenge string supplied by the server.
90
+ * @param difficulty - Required number of leading zero hex digits.
91
+ * @param deadline - Epoch milliseconds after which the search aborts.
92
+ * @returns The matching nonce and its hex digest.
93
+ */
94
+ export function solveAnubisSha256Hex(randomData, difficulty, deadline) {
95
+ if (!Number.isInteger(difficulty) || difficulty < 0 || difficulty > 64)
96
+ throw new Error("Anubis challenge requested an unsupported difficulty.");
97
+ const prefix = "0".repeat(difficulty);
98
+ for (let nonce = 0; nonce <= 0xffff_ffff; nonce += 1) {
99
+ if ((nonce & 0xfff) === 0 && Date.now() > deadline)
100
+ throw new Error("Anubis proof of work exceeded the time budget.");
101
+ const hash = createHash("sha256").update(randomData).update(String(nonce)).digest("hex");
102
+ if (hash.startsWith(prefix))
103
+ return { nonce, hash };
104
+ }
105
+ throw new Error("Anubis proof of work exhausted the nonce space.");
106
+ }
107
+ /**
108
+ * Solves a `sha256`, `hashx`, or `argon2id` challenge by running the
109
+ * server-provided baseline WebAssembly module inside this process.
110
+ *
111
+ * The module is validated before instantiation: it may import only the
112
+ * `anubis_update_nonce` host function and must export the numeric interface the
113
+ * baseline solver uses. The returned hash is checked against the requested
114
+ * difficulty before it is submitted.
115
+ *
116
+ * @param moduleBytes - Compiled bytes of the baseline module.
117
+ * @param randomData - The hex challenge string supplied by the server.
118
+ * @param difficulty - Required number of leading zero bits in the result hash.
119
+ * @param deadline - Epoch milliseconds after which the solve is abandoned.
120
+ * @returns The matching nonce and its hex digest.
121
+ */
122
+ export function solveAnubisWasm(moduleBytes, randomData, difficulty, deadline) {
123
+ if (moduleBytes.byteLength === 0 || moduleBytes.byteLength > ANUBIS_MAX_WASM_BYTES)
124
+ throw new Error("Anubis challenge module exceeded the size limit.");
125
+ if (!Number.isInteger(difficulty) || difficulty < 0 || difficulty > 0xffff_ffff)
126
+ throw new Error("Anubis challenge requested an unsupported difficulty.");
127
+ const data = decodeHex(randomData);
128
+ if (!data || data.byteLength > ANUBIS_MAX_RANDOM_DATA_BYTES)
129
+ throw new Error("Anubis challenge supplied unsupported random data.");
130
+ if (Date.now() > deadline)
131
+ throw new Error("Anubis proof of work exceeded the time budget.");
132
+ const module = new WebAssembly.Module(new Uint8Array(moduleBytes));
133
+ assertSupportedModule(module);
134
+ const instance = new WebAssembly.Instance(module, {
135
+ anubis: { anubis_update_nonce: () => { } },
136
+ });
137
+ const memory = instance.exports.memory;
138
+ if (!(memory instanceof WebAssembly.Memory))
139
+ throw new Error("Anubis challenge module is missing memory.");
140
+ const dataPtr = requireFunction(instance.exports, "data_ptr");
141
+ const setDataLength = requireFunction(instance.exports, "set_data_length");
142
+ const work = requireFunction(instance.exports, "anubis_work");
143
+ const resultHashPtr = requireFunction(instance.exports, "result_hash_ptr");
144
+ const resultHashSize = requireFunction(instance.exports, "result_hash_size");
145
+ writeMemory(memory, dataPtr(), data);
146
+ setDataLength(data.byteLength);
147
+ const nonce = work(difficulty, 0, 1);
148
+ const hashSize = resultHashSize() >>> 0;
149
+ if (hashSize < 1 || hashSize > 4_096)
150
+ throw new Error("Anubis challenge module returned an invalid hash size.");
151
+ const hashBytes = readMemory(memory, resultHashPtr(), hashSize);
152
+ if (Date.now() > deadline)
153
+ throw new Error("Anubis proof of work exceeded the time budget.");
154
+ if (difficulty > 0 && leadingZeroBits(hashBytes) < difficulty)
155
+ throw new Error("Anubis proof of work produced an invalid solution.");
156
+ const hash = Array.from(hashBytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
157
+ return { nonce: nonce >>> 0, hash };
158
+ }
159
+ /** Dispatches one worker solve request to the matching pure solver. */
160
+ export function solveAnubisRequest(request) {
161
+ if (request.kind === "sha256")
162
+ return solveAnubisSha256Hex(request.randomData, request.difficulty, request.deadline);
163
+ return solveAnubisWasm(request.moduleBytes, request.randomData, request.difficulty, request.deadline);
164
+ }
@@ -0,0 +1,15 @@
1
+ /* v8 ignore file -- executes only inside a worker thread; covered through the parent's integration tests. */
2
+ import { parentPort, workerData } from "node:worker_threads";
3
+ import { solveAnubisRequest } from "./anubis-solver.js";
4
+ /** Solves the challenge supplied as worker data and reports the outcome to the parent. */
5
+ function run() {
6
+ const request = workerData;
7
+ try {
8
+ parentPort?.postMessage({ kind: "solved", solution: solveAnubisRequest(request) });
9
+ }
10
+ catch (error) {
11
+ const message = error instanceof Error ? error.message : "Anubis worker failed.";
12
+ parentPort?.postMessage({ kind: "error", message });
13
+ }
14
+ }
15
+ run();
File without changes
File without changes