Files
punktfunk/plugin-kit/src/library/parsers/http.ts
T
enricobuehler 10a0ef3283 style(plugin-kit): adopt the biome config its own plugins already use
The kit had NO biome config and no lint script, while every plugin repo that
consumes it has both. So its source quietly drifted — unused imports, unsorted
imports, formatting — with nothing to catch any of it. Running biome here for
the first time reported 20 findings across 8 files.

Adds `plugin-kit/biome.json` mirroring the plugin repos' (tab indent, double
quotes, recommended lint preset, organizeImports), a `check` script, and
`@biomejs/biome` pinned to the same `^2.5.2` the plugins pin — without that pin
`bunx biome` resolved 2.4.6, which rejects the 2.5 `rules.preset` key.

Two deliberate differences from the plugin repos' copy:

  * no `vcs.useIgnoreFile` — those are standalone repos with a .gitignore beside
    the config; plugin-kit is a directory inside this one, and biome errors with
    "couldn't find an ignore file". The `files.includes` exclusions cover it.
  * `!examples/**/dist` instead of `!ui/dist` — the kit has examples, not a UI.

`css.parser.tailwindDirectives` is carried over and is load-bearing: without it
biome cannot parse `@theme` in src/theme.css and reports three parse errors on
CSS that is perfectly valid Tailwind v4.

Everything here is formatter/import churn except two real findings, both fixed:

  * `Layer` (library/define.ts) and `Cause` (sync-engine.ts) were imported and
    never used;
  * test/spike-httpapi.test.ts read `(reg?.body as …).ui.secret` one line after
    `expect(reg).toBeDefined()`. The optional chain undoes the assertion: had
    `reg` been undefined the `.ui` access would throw a TypeError instead of
    failing the test readably. Now asserted to the type system too.

Wired into plugin-kit-publish.yml as a `Lint & format` step ahead of Typecheck,
so this cannot rot again.

Gates after: biome clean (42 files), tsc clean, 67/67 tests, build clean.
2026-08-08 02:19:06 +02:00

96 lines
3.6 KiB
TypeScript

// The one outbound-HTTP helper a library plugin should use, carrying the host's `fetch_image`
// posture verbatim (art.rs): http(s) only, **no redirects**, a size cap, and a short timeout.
//
// The no-redirect rule is the important one and it is not paranoia: a scanner fetches URLs it read
// out of a launcher's cache — data the plugin did not author. A `3xx` chased automatically is an
// SSRF pivot from a process running on the operator's box (`http://169.254.169.254/…`, an internal
// service). The host learned this in the 2026-07-17 security review; a plugin fetching the same
// class of URL inherits the same rule. A rare legitimately-redirecting CDN just yields no art.
import { Effect } from "effect";
import { HostRequestError } from "../../errors.js";
export interface FetchLimits {
/** Hard cap on the response body. Default 8 MiB — a cover never approaches it. */
readonly maxBytes?: number;
/** Wall-clock timeout in ms. Default 10 000. */
readonly timeoutMs?: number;
}
const DEFAULT_MAX = 8 * 1024 * 1024;
const DEFAULT_TIMEOUT = 10_000;
export interface FetchedBytes {
readonly bytes: Uint8Array;
readonly contentType: string;
}
/**
* GET an `http(s)` URL under the posture above. Fails with {@link HostRequestError} on any non-2xx,
* a redirect, an over-cap body, a timeout, or a non-http(s) scheme.
*
* Most scanners never need this: they emit CDN URLs and let the CLIENT fetch them, which is both
* faster and keeps the host out of the loop. Reach for it only when a store's art requires an API
* lookup the client cannot do (GOG's product API, Microsoft's display catalog).
*/
export const fetchBytes = (
url: string,
limits: FetchLimits = {},
): Effect.Effect<FetchedBytes, HostRequestError> =>
Effect.tryPromise({
try: async (): Promise<FetchedBytes> => {
if (!/^https?:\/\//i.test(url)) {
throw new Error("only http(s) URLs may be fetched");
}
const maxBytes = limits.maxBytes ?? DEFAULT_MAX;
const signal = AbortSignal.timeout(limits.timeoutMs ?? DEFAULT_TIMEOUT);
// `redirect: "manual"` rather than "error": we want to SEE the 3xx and report it as a
// refusal, not have fetch throw something opaque.
const res = await fetch(url, { redirect: "manual", signal });
if (res.status >= 300 && res.status < 400) {
throw new Error(`refusing to follow a ${res.status} redirect`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// Trust Content-Length when it is there (cheap rejection), but still bound the read: a
// hostile server can lie about it or omit it entirely.
const declared = Number(res.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) {
throw new Error(`body larger than ${maxBytes} bytes`);
}
const buf = new Uint8Array(await res.arrayBuffer());
if (buf.byteLength === 0) throw new Error("empty body");
if (buf.byteLength > maxBytes) {
throw new Error(`body larger than ${maxBytes} bytes`);
}
return {
bytes: buf,
contentType: res.headers.get("content-type") ?? "image/jpeg",
};
},
catch: (cause) =>
new HostRequestError({
method: "GET",
path: url,
cause,
}),
});
/** {@link fetchBytes}, JSON-decoded. Same posture; use for a store's public product API. */
export const fetchJson = <T = unknown>(
url: string,
limits: FetchLimits = {},
): Effect.Effect<T, HostRequestError> =>
fetchBytes(url, limits).pipe(
Effect.flatMap((r) =>
Effect.try({
try: () => JSON.parse(new TextDecoder().decode(r.bytes)) as T,
catch: (cause) =>
new HostRequestError({
method: "GET",
path: url,
cause,
}),
}),
),
);