@nijaru/pi-search 0.0.1
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/LICENSE +21 -0
- package/README.md +165 -0
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/package.json +69 -0
- package/src/anthropic.ts +350 -0
- package/src/anydoc-worker.mjs +35 -0
- package/src/anydoc.ts +161 -0
- package/src/brave-answers.ts +396 -0
- package/src/brave.ts +441 -0
- package/src/codex.ts +314 -0
- package/src/contracts.ts +644 -0
- package/src/direct-transport.ts +141 -0
- package/src/errors.ts +185 -0
- package/src/exa.ts +204 -0
- package/src/fetch-errors.ts +134 -0
- package/src/fetch-extractor-worker.mjs +85 -0
- package/src/fetch-tool.ts +166 -0
- package/src/fetcher.ts +451 -0
- package/src/fixtures/sample.csv +2 -0
- package/src/fixtures/sample.docx +0 -0
- package/src/fixtures/sample.epub +0 -0
- package/src/fixtures/sample.odt +0 -0
- package/src/fixtures/sample.pdf +32 -0
- package/src/fixtures/sample.pptx +0 -0
- package/src/fixtures/sample.rtf +1 -0
- package/src/fixtures/sample.xlsx +0 -0
- package/src/gemini.ts +298 -0
- package/src/grounding.ts +167 -0
- package/src/http.ts +78 -0
- package/src/index.ts +101 -0
- package/src/local-llama-compat.ts +134 -0
- package/src/meta.ts +137 -0
- package/src/model-selection.ts +134 -0
- package/src/openai.ts +850 -0
- package/src/parallel-responses.ts +260 -0
- package/src/parallel.ts +173 -0
- package/src/pdf.ts +153 -0
- package/src/provider-availability.ts +35 -0
- package/src/provider-http.ts +203 -0
- package/src/render-text.ts +8 -0
- package/src/research-tool.ts +487 -0
- package/src/router.ts +302 -0
- package/src/search-cleanup.ts +219 -0
- package/src/search-tool.ts +551 -0
- package/src/search.ts +420 -0
- package/src/ssrf.ts +302 -0
- package/src/url-rendering.ts +21 -0
- package/src/x.ts +188 -0
- package/src/xai.ts +254 -0
- package/src/youtube.ts +224 -0
package/src/fetcher.ts
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
import type { FetchRequest, FetchedContent, FetchOutputFormat } from "./contracts";
|
|
3
|
+
import { SafeFetchError } from "./fetch-errors";
|
|
4
|
+
import { closeResponseBody, fetchRemoteUrl, type Lookup, type ResponseBody } from "./ssrf";
|
|
5
|
+
import type { DirectTransport } from "./direct-transport";
|
|
6
|
+
import { extractPdfText } from "./pdf";
|
|
7
|
+
import { extractYouTubeTranscript, parseYouTubeUrl } from "./youtube";
|
|
8
|
+
import {
|
|
9
|
+
AnyDocConversionError,
|
|
10
|
+
anyDocFormatHint,
|
|
11
|
+
convertDocumentWithWorker,
|
|
12
|
+
isAnyDocCandidate,
|
|
13
|
+
isAnyDocMimeType,
|
|
14
|
+
type AnyDocFormat,
|
|
15
|
+
type DocumentConverter,
|
|
16
|
+
} from "./anydoc";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
19
|
+
export const DEFAULT_MAX_LENGTH = 8_000;
|
|
20
|
+
/** Character request bound aligned with the hard output byte bound below. */
|
|
21
|
+
export const MAX_FETCH_LENGTH = 32_000;
|
|
22
|
+
export const MAX_FETCH_OFFSET = MAX_FETCH_LENGTH * 100;
|
|
23
|
+
export const DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
24
|
+
export const MAX_FETCH_RESPONSE_BYTES = 20 * 1024 * 1024;
|
|
25
|
+
const MAX_TITLE_LENGTH = 500;
|
|
26
|
+
const MAX_OUTPUT_BYTES = 32_000;
|
|
27
|
+
|
|
28
|
+
type NormalizedFetchRequest = Required<Pick<FetchRequest, "url" | "maxLength" | "offset" | "format" | "readable" | "allowRawHtmlFallback" | "captionLanguage">> & {
|
|
29
|
+
readonly maxPages: number | undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
interface ExtractedContent {
|
|
33
|
+
readonly content: string;
|
|
34
|
+
readonly outputFormat: FetchOutputFormat;
|
|
35
|
+
readonly extraction: "readability" | "raw" | "plain-text" | "markdown" | "document" | "pdf" | "youtube-transcript";
|
|
36
|
+
readonly documentFormat?: string;
|
|
37
|
+
readonly title?: string;
|
|
38
|
+
readonly fellBackToRaw?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FetcherOptions {
|
|
42
|
+
readonly timeoutMs?: number;
|
|
43
|
+
readonly maxResponseBytes?: number;
|
|
44
|
+
readonly lookup?: Lookup;
|
|
45
|
+
readonly transport?: DirectTransport;
|
|
46
|
+
readonly now?: () => Date;
|
|
47
|
+
readonly pdfCommand?: string;
|
|
48
|
+
readonly youtubeCommand?: string;
|
|
49
|
+
readonly pdfMaxOutputBytes?: number;
|
|
50
|
+
readonly youtubeMaxOutputBytes?: number;
|
|
51
|
+
readonly documentConverter?: DocumentConverter;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function fetchContent(
|
|
55
|
+
request: FetchRequest,
|
|
56
|
+
signal?: AbortSignal,
|
|
57
|
+
options: FetcherOptions = {},
|
|
58
|
+
): Promise<FetchedContent> {
|
|
59
|
+
const normalized = validateFetchRequest(request);
|
|
60
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
61
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
62
|
+
throw new SafeFetchError({ kind: "invalidRequest", message: "timeoutMs must be positive" });
|
|
63
|
+
}
|
|
64
|
+
const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
65
|
+
if (!Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > MAX_FETCH_RESPONSE_BYTES) {
|
|
66
|
+
throw new SafeFetchError({ kind: "invalidRequest", message: "maxResponseBytes is outside the supported bound" });
|
|
67
|
+
}
|
|
68
|
+
if (signal?.aborted) throw new SafeFetchError({ kind: "canceled", message: "Fetch canceled" });
|
|
69
|
+
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
let timedOut = false;
|
|
72
|
+
const timeoutId = setTimeout(() => {
|
|
73
|
+
timedOut = true;
|
|
74
|
+
controller.abort();
|
|
75
|
+
}, timeoutMs);
|
|
76
|
+
const onAbort = (): void => controller.abort();
|
|
77
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
if (parseYouTubeUrl(normalized.url) !== undefined) {
|
|
81
|
+
return await fetchYouTubeContent(normalized, controller.signal, options, options.now ?? (() => new Date()));
|
|
82
|
+
}
|
|
83
|
+
const fetched = await fetchRemoteUrl(normalized.url, {
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
lookup: options.lookup,
|
|
86
|
+
transport: options.transport,
|
|
87
|
+
headers: { "accept-encoding": "identity" },
|
|
88
|
+
});
|
|
89
|
+
const { response, url, redirectCount } = fetched;
|
|
90
|
+
if (response.status < 200 || response.status >= 300) {
|
|
91
|
+
await closeResponseBody(response.body);
|
|
92
|
+
throw new SafeFetchError({
|
|
93
|
+
kind: "http",
|
|
94
|
+
status: response.status,
|
|
95
|
+
retryable: response.status === 408 || response.status === 429 || response.status >= 500,
|
|
96
|
+
message: `HTTP ${response.status}`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
101
|
+
const mimeType = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
102
|
+
const finalUrl = url.url.href;
|
|
103
|
+
const pdfExpectedByMetadata = mimeType === "application/pdf" || isPdfUrl(normalized.url) || isPdfUrl(finalUrl);
|
|
104
|
+
const documentCandidateByMetadata = isAnyDocCandidate(new Uint8Array(), mimeType, normalized.url) || isAnyDocCandidate(new Uint8Array(), mimeType, finalUrl);
|
|
105
|
+
if (!isSupportedContentType(mimeType) && !pdfExpectedByMetadata && !documentCandidateByMetadata) {
|
|
106
|
+
await closeResponseBody(response.body);
|
|
107
|
+
throw new SafeFetchError({ kind: "unsupportedContentType", message: "Response content type is not supported" });
|
|
108
|
+
}
|
|
109
|
+
const responseByteLimit = options.maxResponseBytes === undefined && pdfExpectedByMetadata ? MAX_FETCH_RESPONSE_BYTES : maxResponseBytes;
|
|
110
|
+
const contentLength = parseContentLength(response.headers.get("content-length"));
|
|
111
|
+
if (contentLength !== undefined && contentLength > responseByteLimit) {
|
|
112
|
+
await closeResponseBody(response.body);
|
|
113
|
+
throw new SafeFetchError({ kind: "responseTooLarge", message: "Response exceeds the configured byte limit" });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const bytes = await readBoundedBody(response.body, responseByteLimit, controller.signal);
|
|
117
|
+
const pdfExpected = hasPdfHeader(bytes);
|
|
118
|
+
if (pdfExpectedByMetadata && !pdfExpected) {
|
|
119
|
+
throw new SafeFetchError({
|
|
120
|
+
kind: "extraction",
|
|
121
|
+
message: "Response was identified as PDF but did not contain a valid PDF header",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const documentCandidate = !pdfExpected && (isAnyDocCandidate(bytes, mimeType, normalized.url) || isAnyDocCandidate(bytes, mimeType, finalUrl));
|
|
125
|
+
// AnyDoc has no page-range option; preserve explicit maxPages through the bounded PDF path.
|
|
126
|
+
const extracted = pdfExpected
|
|
127
|
+
? normalized.maxPages === undefined
|
|
128
|
+
? await extractAnyDocContent(bytes, mimeType, finalUrl, controller.signal, options.documentConverter)
|
|
129
|
+
: await extractPdfContent(bytes, normalized, controller.signal, options.pdfCommand, options.pdfMaxOutputBytes)
|
|
130
|
+
: documentCandidate
|
|
131
|
+
? await extractAnyDocContent(bytes, mimeType, finalUrl, controller.signal, options.documentConverter)
|
|
132
|
+
: await extractWithDeadline(decodeUtf8(bytes), mimeType, normalized, controller.signal);
|
|
133
|
+
const sliced = sliceOutput(extracted.content, normalized.offset, normalized.maxLength);
|
|
134
|
+
const warnings = [
|
|
135
|
+
...(extracted.fellBackToRaw ? [{ code: "raw-fallback" as const, message: "Readable extraction failed; bounded raw HTML was returned" }] : []),
|
|
136
|
+
...(sliced.truncated ? [{ code: "truncated" as const, message: sliced.pagingLimitReached ? "Content was truncated at the maximum paging offset" : "Content was truncated to the configured output bound" }] : []),
|
|
137
|
+
];
|
|
138
|
+
const title = extracted.title === undefined ? undefined : boundText(extracted.title, MAX_TITLE_LENGTH);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
url: url.url.href,
|
|
142
|
+
...(url.url.href === normalized.url ? {} : { sourceUrl: normalized.url }),
|
|
143
|
+
...(title === undefined ? {} : { title }),
|
|
144
|
+
content: sliced.content,
|
|
145
|
+
contentTrust: "untrusted",
|
|
146
|
+
...(contentType === "" ? {} : { contentType }),
|
|
147
|
+
outputFormat: extracted.outputFormat,
|
|
148
|
+
extraction: extracted.extraction,
|
|
149
|
+
...(extracted.documentFormat === undefined ? {} : { documentFormat: extracted.documentFormat }),
|
|
150
|
+
fetchedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
151
|
+
status: response.status,
|
|
152
|
+
redirectCount,
|
|
153
|
+
bytesRead: bytes.byteLength,
|
|
154
|
+
truncated: sliced.truncated,
|
|
155
|
+
offset: normalized.offset,
|
|
156
|
+
...(sliced.nextOffset === undefined ? {} : { nextOffset: sliced.nextOffset }),
|
|
157
|
+
totalCharacters: extracted.content.length,
|
|
158
|
+
warnings,
|
|
159
|
+
...(extracted.fellBackToRaw ? { fellBackToRaw: true } : {}),
|
|
160
|
+
};
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (timedOut && !signal?.aborted && isCanceled(error)) {
|
|
163
|
+
throw new SafeFetchError({ kind: "timeout", message: `Fetch timed out after ${timeoutMs} ms`, retryable: true, cause: error });
|
|
164
|
+
}
|
|
165
|
+
if (signal?.aborted && isCanceled(error)) {
|
|
166
|
+
throw new SafeFetchError({ kind: "canceled", message: "Fetch canceled", cause: error });
|
|
167
|
+
}
|
|
168
|
+
throw mapFetchFailure(error);
|
|
169
|
+
} finally {
|
|
170
|
+
clearTimeout(timeoutId);
|
|
171
|
+
signal?.removeEventListener("abort", onAbort);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function validateFetchRequest(request: FetchRequest): NormalizedFetchRequest {
|
|
176
|
+
if (typeof request.url !== "string" || request.url.trim().length === 0) {
|
|
177
|
+
throw new SafeFetchError({ kind: "invalidRequest", message: "URL must not be empty" });
|
|
178
|
+
}
|
|
179
|
+
const url = request.url.trim();
|
|
180
|
+
if (url.length > 8_192) {
|
|
181
|
+
throw new SafeFetchError({ kind: "invalidRequest", message: "URL exceeds the supported length limit" });
|
|
182
|
+
}
|
|
183
|
+
const maxLength = request.maxLength ?? DEFAULT_MAX_LENGTH;
|
|
184
|
+
if (!Number.isInteger(maxLength) || maxLength < 1 || maxLength > MAX_FETCH_LENGTH) {
|
|
185
|
+
throw new SafeFetchError({ kind: "invalidRequest", message: "maxLength is outside the supported bound" });
|
|
186
|
+
}
|
|
187
|
+
const offset = request.offset ?? 0;
|
|
188
|
+
if (!Number.isInteger(offset) || offset < 0 || offset > MAX_FETCH_OFFSET) {
|
|
189
|
+
throw new SafeFetchError({ kind: "invalidRequest", message: "offset is outside the supported bound" });
|
|
190
|
+
}
|
|
191
|
+
const format = request.format ?? "markdown";
|
|
192
|
+
if (format !== "markdown" && format !== "text" && format !== "html") {
|
|
193
|
+
throw new SafeFetchError({ kind: "invalidRequest", message: "format is not supported" });
|
|
194
|
+
}
|
|
195
|
+
const maxPages = request.maxPages;
|
|
196
|
+
if (maxPages !== undefined && (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 500)) {
|
|
197
|
+
throw new SafeFetchError({ kind: "invalidRequest", message: "maxPages must be between 1 and 500" });
|
|
198
|
+
}
|
|
199
|
+
const captionLanguage = request.captionLanguage ?? "en";
|
|
200
|
+
if (!/^[A-Za-z0-9,._*-]{1,32}$/.test(captionLanguage)) {
|
|
201
|
+
throw new SafeFetchError({ kind: "invalidRequest", message: "captionLanguage is invalid" });
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
url,
|
|
205
|
+
maxLength,
|
|
206
|
+
offset,
|
|
207
|
+
format,
|
|
208
|
+
readable: request.readable ?? true,
|
|
209
|
+
allowRawHtmlFallback: request.allowRawHtmlFallback ?? true,
|
|
210
|
+
maxPages,
|
|
211
|
+
captionLanguage,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function extractPdfContent(
|
|
216
|
+
bytes: Uint8Array,
|
|
217
|
+
request: NormalizedFetchRequest,
|
|
218
|
+
signal: AbortSignal,
|
|
219
|
+
command: string | undefined,
|
|
220
|
+
maxOutputBytes: number | undefined,
|
|
221
|
+
): Promise<ExtractedContent> {
|
|
222
|
+
const result = await extractPdfText(bytes, {
|
|
223
|
+
signal,
|
|
224
|
+
command,
|
|
225
|
+
maxPages: request.maxPages,
|
|
226
|
+
maxOutputBytes,
|
|
227
|
+
});
|
|
228
|
+
return { content: result.text, outputFormat: "text", extraction: "pdf" };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function extractAnyDocContent(
|
|
232
|
+
bytes: Uint8Array,
|
|
233
|
+
mimeType: string,
|
|
234
|
+
url: string,
|
|
235
|
+
signal: AbortSignal,
|
|
236
|
+
converter: DocumentConverter | undefined,
|
|
237
|
+
): Promise<ExtractedContent> {
|
|
238
|
+
const formatHint: AnyDocFormat | undefined = anyDocFormatHint(mimeType, url);
|
|
239
|
+
try {
|
|
240
|
+
const result = await (converter ?? convertDocumentWithWorker)(bytes, formatHint, signal);
|
|
241
|
+
return {
|
|
242
|
+
content: result.content,
|
|
243
|
+
outputFormat: "markdown",
|
|
244
|
+
extraction: "document",
|
|
245
|
+
documentFormat: result.documentFormat,
|
|
246
|
+
};
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error instanceof SafeFetchError) throw error;
|
|
249
|
+
if (error instanceof AnyDocConversionError) {
|
|
250
|
+
const message = `AnyDoc ${error.code}: ${error.message}`;
|
|
251
|
+
if (error.code === "unsupported") throw new SafeFetchError({ kind: "unsupportedContentType", message, cause: error });
|
|
252
|
+
// needsOcr names scanned/image-only pages; OCR stays out of scope by
|
|
253
|
+
// design, so it surfaces as an explicit unsupported-content failure.
|
|
254
|
+
if (error.code === "needsOcr") throw new SafeFetchError({ kind: "unsupportedContentType", message, cause: error });
|
|
255
|
+
throw new SafeFetchError({ kind: "extraction", message, cause: error });
|
|
256
|
+
}
|
|
257
|
+
throw new SafeFetchError({ kind: "extraction", message: "Local document conversion failed", cause: error });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function fetchYouTubeContent(
|
|
262
|
+
request: NormalizedFetchRequest,
|
|
263
|
+
signal: AbortSignal,
|
|
264
|
+
options: FetcherOptions,
|
|
265
|
+
now: () => Date,
|
|
266
|
+
): Promise<FetchedContent> {
|
|
267
|
+
const result = await extractYouTubeTranscript(request.url, {
|
|
268
|
+
signal,
|
|
269
|
+
command: options.youtubeCommand,
|
|
270
|
+
language: request.captionLanguage,
|
|
271
|
+
maxOutputBytes: options.youtubeMaxOutputBytes,
|
|
272
|
+
});
|
|
273
|
+
const sliced = sliceOutput(result.text, request.offset, request.maxLength);
|
|
274
|
+
const canonicalUrl = `https://www.youtube.com/watch?v=${result.videoId}`;
|
|
275
|
+
return {
|
|
276
|
+
url: canonicalUrl,
|
|
277
|
+
...(canonicalUrl === request.url ? {} : { sourceUrl: request.url }),
|
|
278
|
+
content: sliced.content,
|
|
279
|
+
contentTrust: "untrusted",
|
|
280
|
+
contentType: "text/plain",
|
|
281
|
+
outputFormat: "text",
|
|
282
|
+
extraction: "youtube-transcript",
|
|
283
|
+
fetchedAt: now().toISOString(),
|
|
284
|
+
status: 200,
|
|
285
|
+
redirectCount: 0,
|
|
286
|
+
bytesRead: result.bytesRead,
|
|
287
|
+
truncated: sliced.truncated,
|
|
288
|
+
offset: request.offset,
|
|
289
|
+
...(sliced.nextOffset === undefined ? {} : { nextOffset: sliced.nextOffset }),
|
|
290
|
+
totalCharacters: result.text.length,
|
|
291
|
+
warnings: sliced.truncated ? [{ code: "truncated", message: "Content was truncated to the configured output bound" }] : [],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isPdfUrl(value: string): boolean {
|
|
296
|
+
try {
|
|
297
|
+
return new URL(value).pathname.toLowerCase().endsWith(".pdf");
|
|
298
|
+
} catch {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function hasPdfHeader(bytes: Uint8Array): boolean {
|
|
304
|
+
return bytes.length >= 5 && new TextDecoder().decode(bytes.slice(0, 5)) === "%PDF-";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function extractWithDeadline(
|
|
308
|
+
sourceText: string,
|
|
309
|
+
mimeType: string,
|
|
310
|
+
request: NormalizedFetchRequest,
|
|
311
|
+
signal: AbortSignal,
|
|
312
|
+
): Promise<ExtractedContent> {
|
|
313
|
+
if (signal.aborted) throw new SafeFetchError({ kind: "canceled", message: "Fetch canceled" });
|
|
314
|
+
if (mimeType === "text/markdown" || mimeType === "text/x-markdown") {
|
|
315
|
+
return { content: sourceText, outputFormat: "markdown", extraction: "markdown" };
|
|
316
|
+
}
|
|
317
|
+
if (mimeType !== "text/html" && mimeType !== "application/xhtml+xml") {
|
|
318
|
+
return { content: sourceText, outputFormat: "text", extraction: "plain-text" };
|
|
319
|
+
}
|
|
320
|
+
return new Promise<ExtractedContent>((resolve, reject) => {
|
|
321
|
+
const worker = new Worker(new URL("./fetch-extractor-worker.mjs", import.meta.url));
|
|
322
|
+
let finished = false;
|
|
323
|
+
const cleanup = (): void => {
|
|
324
|
+
signal.removeEventListener("abort", onAbort);
|
|
325
|
+
void worker.terminate();
|
|
326
|
+
};
|
|
327
|
+
const fail = (error: unknown): void => {
|
|
328
|
+
if (finished) return;
|
|
329
|
+
finished = true;
|
|
330
|
+
cleanup();
|
|
331
|
+
reject(error);
|
|
332
|
+
};
|
|
333
|
+
const onAbort = (): void => fail(new SafeFetchError({ kind: "canceled", message: "Fetch canceled" }));
|
|
334
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
335
|
+
worker.once("error", (error) => fail(new SafeFetchError({ kind: "extraction", message: "Local content extraction failed", cause: error })));
|
|
336
|
+
worker.once("exit", (code) => {
|
|
337
|
+
if (!finished && code !== 0) fail(new SafeFetchError({ kind: "extraction", message: "Local content extraction stopped" }));
|
|
338
|
+
});
|
|
339
|
+
worker.once("message", (message: { ok: boolean; result?: ExtractedContent; error?: { kind: string; message: string } }) => {
|
|
340
|
+
if (!message.ok || message.result === undefined) {
|
|
341
|
+
const kind = message.error?.kind === "extraction" ? "extraction" : "unknown";
|
|
342
|
+
fail(new SafeFetchError({ kind, message: message.error?.message ?? "Local content extraction failed" }));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
finished = true;
|
|
346
|
+
cleanup();
|
|
347
|
+
resolve(message.result);
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
worker.postMessage({ sourceText, mimeType, request });
|
|
351
|
+
} catch (error) {
|
|
352
|
+
fail(new SafeFetchError({ kind: "extraction", message: "Local content extraction could not start", cause: error }));
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
async function readBoundedBody(body: ResponseBody | null, maxBytes: number, signal: AbortSignal): Promise<Uint8Array> {
|
|
359
|
+
if (!body) return new Uint8Array();
|
|
360
|
+
const chunks: Uint8Array[] = [];
|
|
361
|
+
let total = 0;
|
|
362
|
+
const iterator = body[Symbol.asyncIterator]();
|
|
363
|
+
let rejectAborted: ((error: SafeFetchError) => void) | undefined;
|
|
364
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
365
|
+
rejectAborted = reject;
|
|
366
|
+
});
|
|
367
|
+
const onAbort = (): void => rejectAborted?.(new SafeFetchError({ kind: "canceled", message: "Fetch canceled" }));
|
|
368
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
369
|
+
try {
|
|
370
|
+
while (true) {
|
|
371
|
+
const next = await Promise.race([iterator.next(), aborted]);
|
|
372
|
+
if (next.done) break;
|
|
373
|
+
const bytes = next.value instanceof Uint8Array ? next.value : new Uint8Array(next.value);
|
|
374
|
+
total += bytes.byteLength;
|
|
375
|
+
if (total > maxBytes) {
|
|
376
|
+
throw new SafeFetchError({ kind: "responseTooLarge", message: "Response exceeds the configured byte limit" });
|
|
377
|
+
}
|
|
378
|
+
chunks.push(bytes);
|
|
379
|
+
}
|
|
380
|
+
if (signal.aborted) throw new SafeFetchError({ kind: "canceled", message: "Fetch canceled" });
|
|
381
|
+
} finally {
|
|
382
|
+
signal.removeEventListener("abort", onAbort);
|
|
383
|
+
await closeResponseBody(body);
|
|
384
|
+
}
|
|
385
|
+
const result = new Uint8Array(total);
|
|
386
|
+
let offset = 0;
|
|
387
|
+
for (const chunk of chunks) {
|
|
388
|
+
result.set(chunk, offset);
|
|
389
|
+
offset += chunk.byteLength;
|
|
390
|
+
}
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function sliceOutput(content: string, offset: number, maxLength: number): { content: string; truncated: boolean; nextOffset?: number; pagingLimitReached?: boolean } {
|
|
395
|
+
if (offset >= content.length) return { content: "", truncated: false };
|
|
396
|
+
const requestedEnd = Math.min(content.length, offset + maxLength, MAX_FETCH_OFFSET);
|
|
397
|
+
const candidate = content.slice(offset, requestedEnd);
|
|
398
|
+
const bounded = boundUtf8(candidate, MAX_OUTPUT_BYTES);
|
|
399
|
+
const consumed = offset + bounded.length;
|
|
400
|
+
const truncated = consumed < content.length;
|
|
401
|
+
const pagingLimitReached = truncated && consumed >= MAX_FETCH_OFFSET;
|
|
402
|
+
return {
|
|
403
|
+
content: bounded,
|
|
404
|
+
truncated,
|
|
405
|
+
...(truncated && !pagingLimitReached ? { nextOffset: consumed } : {}),
|
|
406
|
+
...(pagingLimitReached ? { pagingLimitReached: true } : {}),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function boundUtf8(value: string, maxBytes: number): string {
|
|
411
|
+
if (new TextEncoder().encode(value).byteLength <= maxBytes) return value;
|
|
412
|
+
let low = 0;
|
|
413
|
+
let high = value.length;
|
|
414
|
+
while (low < high) {
|
|
415
|
+
const middle = Math.ceil((low + high) / 2);
|
|
416
|
+
if (new TextEncoder().encode(value.slice(0, middle)).byteLength <= maxBytes) low = middle;
|
|
417
|
+
else high = middle - 1;
|
|
418
|
+
}
|
|
419
|
+
return value.slice(0, low);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function boundText(value: string, maxLength: number): string {
|
|
423
|
+
return value.length <= maxLength ? value : value.slice(0, maxLength);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function decodeUtf8(bytes: Uint8Array): string {
|
|
427
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function parseContentLength(value: string | null): number | undefined {
|
|
431
|
+
if (value === null || !/^\d+$/.test(value.trim())) return undefined;
|
|
432
|
+
const parsed = Number(value);
|
|
433
|
+
return Number.isSafeInteger(parsed) ? parsed : Number.MAX_SAFE_INTEGER;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function isSupportedContentType(mimeType: string): boolean {
|
|
437
|
+
if (mimeType === "") return true;
|
|
438
|
+
if (mimeType === "application/pdf") return false;
|
|
439
|
+
if (isAnyDocMimeType(mimeType)) return true;
|
|
440
|
+
if (/^(image|audio|video)\//.test(mimeType)) return false;
|
|
441
|
+
return mimeType.startsWith("text/") || mimeType === "application/json" || mimeType === "application/xml" || mimeType === "application/javascript" || mimeType === "application/x-javascript" || mimeType === "application/graphql" || mimeType.endsWith("+json") || mimeType.endsWith("+xml");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function isCanceled(error: unknown): boolean {
|
|
445
|
+
return (error instanceof SafeFetchError && error.kind === "canceled") || (error instanceof Error && /abort|cancell?ed/i.test(error.message));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function mapFetchFailure(error: unknown): SafeFetchError {
|
|
449
|
+
if (error instanceof SafeFetchError) return error;
|
|
450
|
+
return new SafeFetchError({ kind: "network", message: "Direct fetch failed", retryable: true, cause: error });
|
|
451
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
%PDF-1.4
|
|
2
|
+
1 0 obj
|
|
3
|
+
<< /Type /Catalog /Pages 2 0 R >>
|
|
4
|
+
endobj
|
|
5
|
+
2 0 obj
|
|
6
|
+
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
|
7
|
+
endobj
|
|
8
|
+
3 0 obj
|
|
9
|
+
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
|
|
10
|
+
endobj
|
|
11
|
+
4 0 obj
|
|
12
|
+
<< /Length 44 >>
|
|
13
|
+
stream
|
|
14
|
+
BT /F1 18 Tf 72 720 Td (PDF evidence.) Tj ET
|
|
15
|
+
endstream
|
|
16
|
+
endobj
|
|
17
|
+
5 0 obj
|
|
18
|
+
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
|
19
|
+
endobj
|
|
20
|
+
xref
|
|
21
|
+
0 6
|
|
22
|
+
0000000000 65535 f
|
|
23
|
+
0000000009 00000 n
|
|
24
|
+
0000000058 00000 n
|
|
25
|
+
0000000115 00000 n
|
|
26
|
+
0000000241 00000 n
|
|
27
|
+
0000000335 00000 n
|
|
28
|
+
trailer
|
|
29
|
+
<< /Size 6 /Root 1 0 R >>
|
|
30
|
+
startxref
|
|
31
|
+
405
|
|
32
|
+
%%EOF
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{\rtf1\ansi\b RTF title\b0\par RTF evidence.}
|
|
Binary file
|