import { readFileSync } from 'node:fs';
import { check } from './schema.js';
const load = (f) => JSON.parse(readFileSync(new URL(`./schemas/${f}`, import.meta.url), 'utf8'));
export const MANIFEST_SCHEMA = load('manifest.schema.json');
export const SECTION_SCHEMA = load('section.schema.json');
export const MANIFEST_PATHS = ['/.well-known/aiax.json', '/aiax/manifest.json'];
/** Score: 100 with no error and no warning; each error costs 15 points, each warning 2; never below 0. */
export const ERROR_COST = 15;
export const WARNING_COST = 2;
export const estTokens = (text) => Math.ceil(text.length / 4);
async function getJson(fetchFn, url) {
let res;
try { res = await fetchFn(url, { headers: { accept: 'application/json' }, redirect: 'follow' }); }
catch (e) { return { error: `could not be fetched: ${e?.message ?? e}` }; }
if (!res || !res.ok) return { error: `could not be fetched: HTTP ${res?.status ?? 'error'}`, status: res?.status };
const text = await res.text();
try { return { json: JSON.parse(text), text }; } catch { return { error: 'is not JSON', text }; }
}
export async function validateSite(url, opts = {}) {
const fetchFn = opts.fetch ?? globalThis.fetch;
const base = new URL(/^[a-z]+:\/\//i.test(url) ? url : `https://${url}`);
const errors = [], warnings = [], sections = [];
let manifestUrl = null, manifest = null, mtext = '';
const tried = [];
for (const p of MANIFEST_PATHS) {
const u = new URL(p, base).toString();
const r = await getJson(fetchFn, u);
if (r.json !== undefined) { manifestUrl = u; manifest = r.json; mtext = r.text; break; }
tried.push(`${u} ${r.error}`);
if (r.error === 'is not JSON') { manifestUrl = u; errors.push({ url: u, path: '', message: 'the manifest is not JSON' }); break; }
}
if (!manifestUrl) errors.push({ url: new URL(MANIFEST_PATHS[0], base).toString(), path: '', message: `no AIAX manifest found: ${tried.join('; ')}` });
if (manifest !== null) {
for (const e of check(MANIFEST_SCHEMA, manifest)) errors.push({ url: manifestUrl, ...e });
if (estTokens(mtext) > 600) warnings.push({ url: manifestUrl, path: '', message: `the manifest is about ${estTokens(mtext)} tokens; AIAX targets 100 to 300` });
const eps = Array.isArray(manifest.entry_points) ? manifest.entry_points : [];
const ids = new Set();
for (const [i, ep] of eps.entries()) {
if (!ep || typeof ep !== 'object' || typeof ep.url !== 'string') continue;
if (ids.has(ep.id)) errors.push({ url: manifestUrl, path: `/entry_points/${i}/id`, message: `entry_points.${i}.id ${JSON.stringify(ep.id)} is repeated` });
ids.add(ep.id);
let su;
try { su = new URL(ep.url, manifestUrl).toString(); } catch { continue; }
const r = await getJson(fetchFn, su);
const sec = { id: ep.id, url: su, ok: true, errors: [] };
const err = (path, message) => { const e = { url: su, path, message }; sec.errors.push(e); errors.push(e); };
if (r.json === undefined) err('', `the section ${JSON.stringify(ep.id)} ${r.error}`);
else {
for (const e of check(SECTION_SCHEMA, r.json)) err(e.path, e.message);
if (r.json && typeof r.json === 'object' && 'section_id' in r.json && r.json.section_id !== ep.id) err('/section_id', `section_id ${JSON.stringify(r.json.section_id)} differs from its entry point id ${JSON.stringify(ep.id)}`);
const real = estTokens(r.text);
if (Number.isInteger(ep.est_tokens) && (real > ep.est_tokens * 2 || real * 2 < ep.est_tokens)) warnings.push({ url: manifestUrl, path: `/entry_points/${i}/est_tokens`, message: `entry_points.${i}.est_tokens says ${ep.est_tokens}, the section is about ${real} tokens` });
}
sec.ok = sec.errors.length === 0;
sections.push(sec);
}
}
const score = Math.max(0, 100 - errors.length * ERROR_COST - warnings.length * WARNING_COST);
return { url: base.toString(), manifest_url: manifestUrl, ok: errors.length === 0, score, errors, warnings, sections };
}
/** A fetch that answers from files: the URL path under dir (dir/.well-known/aiax.json); a missing file is a 404. */
export function fixtureFetch(dir) {
return async (url) => {
const { pathname } = new URL(String(url));
if (pathname.includes('..')) return new Response('bad path', { status: 400 });
try { return new Response(readFileSync(`${dir.replace(/\/$/, '')}${decodeURIComponent(pathname)}`), { status: 200 }); }
catch { return new Response('not found', { status: 404 }); }
};
}
agentgate-source: code mirror on Cloudflare Pages. All repos