'use strict'; import {spawn, execFile} from 'node:child_process'; import {promisify} from 'node:util'; import net from 'node:net'; import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {decodeHtml, fromXml} from '@ztimson/utils'; import {fuzzyMatch, weightedScore} from './utils.js'; const execFileAsync = promisify(execFile); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DEFAULT_BIN_DIR = path.join(__dirname, '..', 'bin'); // npm package root/bin - where bin/install.js drops the kiwix-tools binaries const READY_TIMEOUT = 10_000; const READY_POLL_INTERVAL = 100; function findFreePort() { return new Promise((resolve, reject) => { const srv = net.createServer(); srv.on('error', reject); srv.listen(0, () => { const {port} = srv.address(); srv.close(() => resolve(port)); }); }); } /** Owns a kiwix-serve process's full lifecycle: library.xml, start/stop/reload, content + search access. */ export class KiwixServer { #dir; #host; #port; #binDir; #libraryPath; #child = null; #remote; // baseUrl string if attached to an externally-managed kiwix-serve, else null get port() { return this.#port; } get running() { return !!this.#remote || !!this.#child; } get baseUrl() { return this.#remote || (this.#child ? `http://${this.#host}:${this.#port}` : null); } /** @param {{port?: number, host?: string, binDir?: string, url?: string}} [opts] * url: attach to an already-running kiwix-serve (e.g. one started elsewhere in your codebase) instead of * spawning/owning one - start/stop/reload become no-ops, and library.xml is read over HTTP instead of disk. */ constructor(dir, {port, host = '127.0.0.1', binDir = DEFAULT_BIN_DIR, url} = {}) { this.#dir = dir; this.#host = host; this.#port = port; this.#binDir = binDir; this.#libraryPath = path.join(dir, 'library.xml'); this.#remote = url ? url.replace(/\/$/, '') : null; } #assertRunning() { if (!this.running) throw new Error('KiwixServer is not running - call start() first'); } #bin(name) { return path.join(this.#binDir, process.platform === 'win32' ? `${name}.exe` : name); } /** Reads library.xml from disk if we own the server, or over HTTP if attached to a remote one. */ async #fetchLibraryXml() { if (this.#remote) return (await fetch(`${this.#remote}/library.xml`).catch(() => null))?.text?.() ?? ''; return fs.promises.readFile(this.#libraryPath, 'utf8').catch(() => ''); } /** Rebuilds library.xml from scratch by scanning `dir` for .zim files - no-op if attached to a remote server. */ async #rebuildLibrary() { if (this.#remote) return; await fs.promises.rm(this.#libraryPath, {force: true}); for (const f of await this.#zimFiles()) await execFileAsync(this.#bin('kiwix-manage'), [this.#libraryPath, 'add', path.join(this.#dir, f)]); } async #waitUntilReady() { const deadline = Date.now() + READY_TIMEOUT; while (Date.now() < deadline) { try { await fetch(`http://${this.#host}:${this.#port}/`); return; } catch {} await new Promise(r => setTimeout(r, READY_POLL_INTERVAL)); } throw new Error('kiwix-serve did not become ready in time'); } async #zimFiles() { return (await fs.promises.readdir(this.#dir).catch(() => [])).filter(f => f.endsWith('.zim')); } /** Rebuilds library.xml and starts kiwix-serve. Resolves once the server is responding. */ async start() { if (this.#remote || this.#child) return; await fs.promises.mkdir(this.#dir, {recursive: true}); await this.#rebuildLibrary(); this.#port ??= await findFreePort(); this.#child = spawn(this.#bin('kiwix-serve'), ['--library', '-i', this.#host, '-p', String(this.#port), this.#libraryPath], {stdio: 'ignore'}); this.#child.on('exit', () => { this.#child = null; }); try { await this.#waitUntilReady(); } catch (e) { await this.stop(); throw e; } } /** Gracefully stops kiwix-serve, if we own it. No-op if attached to a remote instance. */ async stop() { if (this.#remote || !this.#child) return; const child = this.#child; await new Promise(resolve => { child.once('exit', resolve); child.kill('SIGTERM'); }); this.#child = null; } async restart() { await this.stop(); await this.start(); } /** Rebuilds library.xml from disk and restarts kiwix-serve. No-op if attached to a remote instance - * whoever owns that process is responsible for reloading it. */ async reload() { if (this.#remote || !this.#child) return; await this.restart(); } /** Local catalog listing - same flat shape as the online catalog (catalog.js), plus a `file` field. */ async list() { this.#assertRunning(); const xml = await this.#fetchLibraryXml(); const entries = fromXml(xml); return (entries?.library?.book || []).map(e => { const tags = e.tags.split(';'); const name = e.path.replaceAll('.zim', ''); return { id: e.id, title: e.title, updated: new Date(e.date), summary: e.description, language: e.language, name: e.name, category: tags.find(t => t.startsWith('_category'))?.slice(10) || '', tags, mediaCount: +e.mediaCount || 0, author: e.creator, publisher: e.publisher, articleCount: +e.articleCount || 0, sizeMb: +(Number(e.size) / 1024).toFixed(1) || 0, href: name, icon: `data:${e.faviconMimetype || 'image/png'};base64,${e.favicon}`, viewer: `${this.baseUrl}/content/${name}`, }; }); } /** Splits a href ("zim/path/to/page") or a full content/viewer URL into {zim, path}. */ #splitHref(href) { const clean = href.replace(`${this.baseUrl}/content/`, '').replace(/^\/+/, ''); const [zim, ...rest] = clean.split('/'); return {zim, path: rest.join('/')}; } /** Builds a kiwix-serve content URL from a href (as returned by list()/search()), or from an already-built content/viewer URL. */ link(href) { this.#assertRunning(); const {zim, path} = this.#splitHref(href); return `${this.baseUrl}/content/${zim}${path ? '/' + path : ''}`; } /** Fetches a single asset's raw bytes straight from kiwix-serve. */ async raw(href) { const res = await fetch(this.link(href)); if(!res.ok) return null; return {mimetype: res.headers.get('content-type'), data: Buffer.from(await res.arrayBuffer())}; } /** * Two-pass fulltext search across every local ZIM: xapian prefilter (kiwix-serve's * own index), then fuzzy-reranked by title so the strongest matches surface first. * Returns a flat array matching the catalog/list shape: {id, title, name, category, ..., href, score} */ async search(terms, limit = 20) { this.#assertRunning(); const termList = String(terms).split(',').map(t => t.trim()).filter(Boolean); if (!termList.length) return []; const params = new URLSearchParams({pattern: termList.join(' '), format: 'xml', pageLength: String(limit)}); const res = await fetch(`${this.baseUrl}/search?${params}`); if (!res.ok) return []; const xml = await res.text(); let found = fromXml(xml); found = found?.rss?.channel?.item || []; const books = await this.list(); const bookMap = new Map(books.map(b => [b.title, b])); const enriched = found.map(hit => { const book = bookMap.get(hit.book.title); if(!book) return null; const prefix = `/content/${book.href}/`; const page = hit.link.startsWith(prefix) ? hit.link.slice(prefix.length) : hit.link.replace(/^\/+/, ''); return { id: book.id, title: hit.title, page, name: book.name, publisher: book.publisher, href: `${book.href}/${page}`, icon: book.icon, viewer: this.baseUrl + hit.link, summary: hit.description, score: weightedScore(hit.title, termList) + weightedScore(hit.description, termList), }; }).filter(hit => !!hit && hit.score > 0); return enriched.toSorted((a, b) => b.score - a.score).slice(0, limit); } }