File size: 8,740 Bytes
6ab11ea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | // Open Art Search — server-side search API (read-only JSON over HTTP) for agents and scripts.
// node server/server.mjs PORT (7860), DATA_DIR (public/data), INDEX_BUILD (the only build present),
// APP_URL (the static app, for self-hosted thumbnails and links), PUBLIC_URL (optional)
// Everything is loaded before the port opens, so a running server always has the full index (and normally the model).
// Query text is processed in memory to rank results and is never logged or stored.
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadCollection } from './collection.mjs';
import { loadEmbedder } from './embed.mjs';
import { ROUTES, openapi } from './routes.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const PORT = +process.env.PORT || 7860;
const DATA_DIR = path.resolve(here, '..', process.env.DATA_DIR || 'public/data');
const APP_URL = process.env.APP_URL || 'https://mike0021-open-art-search.static.hf.space/';
const RATE_PER_MIN = +process.env.RATE_PER_MIN || 120; // per client IP
const MAX_QUEUE = 16; // semantic searches waiting for the (serialised) query model before we answer 503
const builds = fs.readdirSync(DATA_DIR).filter((d) => !d.startsWith('.')).sort();
const BUILD = process.env.INDEX_BUILD || (builds.length === 1 ? builds[0] : null);
if (!BUILD || !builds.includes(BUILD)) throw new Error(`set INDEX_BUILD: ${DATA_DIR} has ${builds.join(', ') || 'no builds'}`);
const t0 = performance.now();
const embedder = loadEmbedder();
const { M, tools, indexMs, model } = await loadCollection({ dir: path.join(DATA_DIR, BUILD), appUrl: APP_URL, embedder });
await embedder.catch((e) => console.error('query model failed to load; serving title/artist keyword matching only:', e));
const exampleSearch = await tools.search_artworks.execute({ query: 'a cat sleeping', limit: 2 }); // shown in the docs
console.log(JSON.stringify({ build: BUILD, artworks: M.count, indexMs: Math.round(indexMs), model: model.state, modelLoadMs: Math.round(model.s?.loadMs ?? 0), startupMs: Math.round(performance.now() - t0) }));
const routes = ROUTES.map((r) => ({ ...r, re: new RegExp('^' + r.path.replace('{id}', '([^/]+)') + '/?$') }));
const docsTemplate = fs.readFileSync(path.join(here, 'docs.html'), 'utf8');
function publicUrl(req) {
if (process.env.PUBLIC_URL) return process.env.PUBLIC_URL.replace(/\/$/, '');
if (process.env.SPACE_HOST) return `https://${process.env.SPACE_HOST}`; // set by Hugging Face Spaces
return `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`;
}
class HttpError extends Error {
constructor(status, code, message, headers = {}) { super(message); Object.assign(this, { status, code, headers }); }
}
const withPrefix = (id) => (/^\d{1,9}$/.test(id) ? `met:${id}` : id); // bare Met object IDs are accepted too
// query string -> tool arguments, typed per the tool's input schema (validation itself stays in agent-tools.js)
function argsFrom(route, params, idParam) {
const props = tools[route.tool].inputSchema.properties;
const args = {};
if (idParam != null) {
let id;
try { id = decodeURIComponent(idParam); } catch { throw new HttpError(400, 'invalid_argument', 'malformed artwork id'); }
args.id = withPrefix(id);
}
for (const key of new Set(params.keys())) {
const name = key === 'q' && route.query.includes('query') ? 'query' : key;
if (!route.query.includes(name)) {
throw new HttpError(400, 'invalid_argument', `unknown parameter "${key}"` + (route.query.length ? ` (accepted: ${route.query.join(', ')})` : ' (this endpoint takes no query parameters)'));
}
const all = params.getAll(key);
if (all.length > 1 || name in args) throw new HttpError(400, 'invalid_argument', `parameter "${name}" given more than once`);
const raw = all[0];
if (raw === '' && name !== 'query') continue; // empty = not set
const t = props[name].type;
args[name] = t === 'integer' && /^[+-]?\d+$/.test(raw.trim()) ? Number(raw)
: t === 'array' ? raw.split(',').map((s) => withPrefix(s.trim())).filter(Boolean) // ids
: raw;
}
return args;
}
const buckets = new Map(); // ip -> { tokens, t }
function rateLimit(ip) {
const now = Date.now();
const b = buckets.get(ip) || { tokens: RATE_PER_MIN, t: now };
b.tokens = Math.min(RATE_PER_MIN, b.tokens + ((now - b.t) / 60000) * RATE_PER_MIN);
b.t = now;
buckets.set(ip, b);
if (b.tokens < 1) throw new HttpError(429, 'rate_limited', `more than ${RATE_PER_MIN} requests per minute`, { 'Retry-After': String(Math.ceil(((1 - b.tokens) * 60) / RATE_PER_MIN)) });
b.tokens -= 1;
}
setInterval(() => { const old = Date.now() - 120000; for (const [ip, b] of buckets) if (b.t < old) buckets.delete(ip); }, 60000).unref();
let queued = 0;
async function callTool(route, args) {
const semantic = route.tool === 'search_artworks' && (args.query || '').trim() && model.state === 'ready';
if (semantic && queued >= MAX_QUEUE) throw new HttpError(503, 'busy', 'too many searches in progress, retry shortly', { 'Retry-After': '2' });
if (semantic) queued++;
try { return await tools[route.tool].execute(args); }
catch (e) {
if (e.code === 'invalid_argument') throw new HttpError(400, e.code, e.message);
if (e.code === 'not_found') throw new HttpError(404, e.code, e.message);
throw e;
} finally { if (semantic) queued--; }
}
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
function docsPage(base) {
const vars = {
BASE: base, APP: APP_URL, BUILD: M.build, COUNT: M.count.toLocaleString('en-US'), RATE: RATE_PER_MIN,
EXAMPLE: JSON.stringify(exampleSearch, null, 2),
};
return docsTemplate.replace(/\{\{(\w+)\}\}/g, (_, k) => esc(vars[k] ?? ''));
}
const COMMON = { 'Access-Control-Allow-Origin': '*', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };
function send(res, status, body, type, headers = {}) {
res.writeHead(status, { ...COMMON, 'Content-Type': type, ...headers });
res.end(res.req.method === 'HEAD' ? undefined : body);
}
const sendJSON = (res, status, obj, headers) => send(res, status, JSON.stringify(obj, null, 2) + '\n', 'application/json; charset=utf-8', headers);
async function handle(req, res) {
const url = new URL(req.url, 'http://x');
const base = publicUrl(req);
if (req.method === 'OPTIONS') return send(res, 204, '', 'text/plain', { 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': '*', 'Access-Control-Max-Age': '86400' });
if (req.method !== 'GET' && req.method !== 'HEAD') throw new HttpError(405, 'method_not_allowed', 'the API is read-only: use GET', { Allow: 'GET, HEAD, OPTIONS' });
if (url.pathname === '/' || url.pathname === '/docs') {
return send(res, 200, docsPage(base), 'text/html; charset=utf-8', {
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action 'none'",
'Cache-Control': 'public, max-age=300',
});
}
if (url.pathname === '/openapi.json') return sendJSON(res, 200, openapi({ tools, M, baseUrl: base, appUrl: APP_URL }), { 'Cache-Control': 'public, max-age=300' });
if (url.pathname === '/favicon.ico') return send(res, 204, '', 'image/x-icon');
const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
for (const r of routes) {
const m = url.pathname.match(r.re);
if (!m) continue;
rateLimit(ip);
return sendJSON(res, 200, await callTool(r, argsFrom(r, url.searchParams, m[1])), { 'Cache-Control': 'no-store' });
}
throw new HttpError(404, 'not_found', `no such endpoint ${url.pathname}; see ${base}/openapi.json`);
}
const server = http.createServer(async (req, res) => {
const t = performance.now();
try { await handle(req, res); }
catch (e) {
const status = e instanceof HttpError ? e.status : 500;
if (status === 500) console.error(e);
sendJSON(res, status, { error: { status, code: e.code || 'internal', message: status === 500 ? 'internal error' : e.message }, docs: `${publicUrl(req)}/` }, e.headers);
} finally {
// method, path (no query string: queries are not logged), status, duration
console.log(`${req.method} ${new URL(req.url, 'http://x').pathname.slice(0, 120)} ${res.statusCode} ${Math.round(performance.now() - t)}ms`);
}
});
server.requestTimeout = 30000;
server.headersTimeout = 15000;
server.listen(PORT, () => console.log(`listening on :${PORT}`));
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => server.close(() => process.exit(0)));
|