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