// 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)));