Skip to content

Commit 70cde26

Browse files
committed
Detect: sniff the OpenAPI spec, never parse it (existence + URL only)
Pure detection: peek the first 4KB of candidate spec paths and regex for the openapi/swagger marker + version instead of downloading and JSON.parsing the whole spec. The endpoint now proxies back the spec URL without interpreting its contents — no parsing of fetched artifacts.
1 parent aa6bcad commit 70cde26

1 file changed

Lines changed: 36 additions & 5 deletions

File tree

src/lib/detect.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,34 @@ async function get(
7878
}
7979
}
8080

81+
/** Like get(), but reads only the first chunk — for sniffing big artifacts
82+
* (e.g. OpenAPI specs) without downloading or parsing the whole body. */
83+
async function peek(
84+
fetchImpl: FetchLike,
85+
url: string,
86+
init?: RequestInit,
87+
maxBytes = 4096,
88+
): Promise<{ res: Response; head: string } | null> {
89+
const ctrl = new AbortController();
90+
const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
91+
try {
92+
const headers = { "user-agent": "integrations.sh-detector/0.1 (+https://integrations.sh)", ...(init?.headers as Record<string, string> | undefined) };
93+
const res = await fetchImpl(url, { redirect: "follow", ...init, headers, signal: ctrl.signal });
94+
if (!res.body) {
95+
const text = await res.text();
96+
return { res, head: text.slice(0, maxBytes) };
97+
}
98+
const reader = res.body.getReader();
99+
const { value } = await reader.read();
100+
reader.cancel().catch(() => {});
101+
return { res, head: value ? new TextDecoder().decode(value).slice(0, maxBytes) : "" };
102+
} catch {
103+
return null;
104+
} finally {
105+
clearTimeout(t);
106+
}
107+
}
108+
81109
/** Parse JSON only when it actually looks like JSON (guards SPA/HTML fallbacks). */
82110
function asJson(text: string, contentType: string | null): any | null {
83111
const trimmed = text.trimStart();
@@ -149,14 +177,17 @@ async function checkLlmsTxt(fetchImpl: FetchLike, domain: string): Promise<boole
149177
*/
150178
async function checkApiSchema(fetchImpl: FetchLike, domain: string) {
151179
const paths = ["/api/schema/", "/openapi.json", "/swagger.json", "/api/openapi.json", "/v1/openapi.json"];
152-
// Probe all candidate paths concurrently; keep the first (by order) that is a spec.
180+
// Probe concurrently; sniff only the head (no body download, no parse).
153181
const results = await Promise.all(paths.map(async (p) => {
154-
const hit = await get(fetchImpl, `https://${domain}${p}`);
182+
const url = `https://${domain}${p}`;
183+
const hit = await peek(fetchImpl, url);
155184
if (!hit || !hit.res.ok) return undefined;
156185
const ct = hit.res.headers.get("content-type") ?? "";
157-
const doc = asJson(hit.text, ct);
158-
const isOpenapi = /openapi/i.test(ct) || Boolean(doc && (doc.openapi || doc.swagger));
159-
return isOpenapi ? { url: `https://${domain}${p}`, format: "openapi" as const, version: doc?.openapi ?? doc?.swagger } : undefined;
186+
if (/text\/html/i.test(ct)) return undefined; // SPA fallback
187+
const isOpenapi = /openapi|swagger/i.test(ct) || /["']?(?:openapi|swagger)["']?\s*:/.test(hit.head);
188+
if (!isOpenapi) return undefined;
189+
const version = /["']?(?:openapi|swagger)["']?\s*:\s*["']([^"']+)["']/.exec(hit.head)?.[1];
190+
return { url, format: "openapi" as const, version };
160191
}));
161192
return results.find(Boolean);
162193
}

0 commit comments

Comments
 (0)