mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 23:07:29 +00:00
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
/**
|
|
* `http://` and `https://` adapter.
|
|
*
|
|
* Remote content is data, never instruction. Whatever comes back is marked
|
|
* untrusted unless the object declares a digest that matches, in which case it
|
|
* is `verified` — integrity is a claim about bytes, not about intent, so even a
|
|
* digest-matched document never becomes `trusted`.
|
|
*/
|
|
|
|
import type { Adapter, AdapterContext, AdapterResult } from "../types.js";
|
|
import { sha256Uri } from "../digest.js";
|
|
|
|
export class OfflineError extends Error {
|
|
constructor(uri: string) {
|
|
super(`Cannot fetch ${uri} in --offline mode. Run without --offline, or inline the content.`);
|
|
this.name = "OfflineError";
|
|
}
|
|
}
|
|
|
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
const MAX_BYTES = 5 * 1024 * 1024;
|
|
|
|
async function readBody(response: Response, uri: string): Promise<string> {
|
|
if (!response.body) return "";
|
|
|
|
const reader = response.body.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let totalBytes = 0;
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
totalBytes += value.byteLength;
|
|
if (totalBytes > MAX_BYTES) {
|
|
await reader.cancel().catch(() => undefined);
|
|
throw new Error(`Refusing to load ${uri}: response exceeds the ${MAX_BYTES} byte limit.`);
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
|
|
const bytes = new Uint8Array(totalBytes);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
bytes.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
return new TextDecoder().decode(bytes);
|
|
}
|
|
|
|
export const httpAdapter: Adapter = {
|
|
name: "http",
|
|
schemes: ["http", "https"],
|
|
remote: true,
|
|
async load(uri: string, ctx: AdapterContext): Promise<AdapterResult> {
|
|
if (ctx.offline) throw new OfflineError(uri);
|
|
|
|
const url = new URL(uri);
|
|
if (url.protocol === "http:" && ctx.config.allow_insecure !== true) {
|
|
throw new Error(
|
|
`Refusing to fetch ${uri} over plaintext http. Use https, or set adapters.http.allow_insecure: true ` +
|
|
`if this is a trusted network you control.`
|
|
);
|
|
}
|
|
|
|
const timeoutMs = ctx.timeoutMs ?? (ctx.config.timeout_ms as number | undefined) ?? DEFAULT_TIMEOUT_MS;
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
try {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, {
|
|
signal: controller.signal,
|
|
// Redirects can move a request to a host the author never named, so the
|
|
// final URL is reported back rather than followed silently.
|
|
redirect: "follow",
|
|
headers: { accept: "text/markdown, text/plain, application/json;q=0.9, */*;q=0.8" }
|
|
});
|
|
} catch (error) {
|
|
throw new Error(`Failed to fetch ${uri}: ${(error as Error).message}`);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch ${uri}: HTTP ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const declaredLength = Number(response.headers.get("content-length") ?? "0");
|
|
if (declaredLength > MAX_BYTES) {
|
|
throw new Error(`Refusing to load ${uri}: ${declaredLength} bytes exceeds the ${MAX_BYTES} byte limit.`);
|
|
}
|
|
|
|
const content = await readBody(response, uri);
|
|
|
|
return {
|
|
content,
|
|
contentType: (response.headers.get("content-type") ?? "text/plain").split(";")[0]!.trim(),
|
|
digest: sha256Uri(content),
|
|
retrievedAt: new Date().toISOString(),
|
|
trust: (ctx.config.trust as AdapterResult["trust"]) ?? "untrusted"
|
|
};
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
};
|