diff --git a/package.json b/package.json index e8ab8b6..7eb26e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/zim-utils", - "version": "0.3.0", + "version": "0.3.1", "description": "Native, dependency-light ZIM archive reader/searcher and Kiwix catalog downloader for Node.js", "author": "Zak Timson", "license": "MIT", diff --git a/src/manager.js b/src/manager.js index 1b4cfca..e925da3 100644 --- a/src/manager.js +++ b/src/manager.js @@ -13,13 +13,15 @@ export class ZimManager { server; #ownsServer; - /** @param {{catalog?: string, server?: KiwixServer, port?: number, host?: string, binDir?: string}} [opts] - * server: reuse an existing KiwixServer; if omitted, one is created (not yet started) and exposed via `.server` for reuse elsewhere. */ - constructor(dir, {catalog = CATALOG_URL, server, port, host, binDir} = {}) { + /** @param {{catalog?: string, server?: KiwixServer, port?: number, host?: string, binDir?: string, url?: string}} [opts] + * server: reuse an existing KiwixServer instance directly. + * url: attach to an already-running kiwix-serve by address instead (e.g. started elsewhere in your codebase + * with no shared reference) - mutually exclusive with `server`, ignored if `server` is given. */ + constructor(dir, {catalog = CATALOG_URL, server, port, host, binDir, url} = {}) { this.#catalogUrl = catalog; this.#dir = dir; this.#ownsServer = !server; - this.server = server ?? new KiwixServer(dir, {port, host, binDir}); + this.server = server ?? new KiwixServer(dir, {port, host, binDir, url}); } /** The KiwixServer backing this manager - reuse it directly for content/search access, or pass into another ZimManager. */ diff --git a/src/server.js b/src/server.js index f0d3005..9e742bc 100644 --- a/src/server.js +++ b/src/server.js @@ -34,30 +34,41 @@ export class KiwixServer { #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.#child; } - get baseUrl() { return this.#child ? `http://${this.#host}:${this.#port}` : null; } + 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}} [opts] port defaults to an auto-picked free port; host defaults to localhost-only; binDir defaults to the bundled ./bin next to this package. */ - constructor(dir, {port, host = '127.0.0.1', binDir = DEFAULT_BIN_DIR} = {}) { + /** @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.#child) throw new Error('KiwixServer is not running - call start() first'); + 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); } - /** Rebuilds library.xml from scratch by scanning `dir` for .zim files - a full rebuild is simpler and less bug-prone than tracking incremental add/remove. */ + /** 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)]); } @@ -78,15 +89,9 @@ export class KiwixServer { return (await fs.promises.readdir(this.#dir).catch(() => [])).filter(f => f.endsWith('.zim')); } - /** URL for a single asset, e.g. for pulling one html/image file when you already know the path (from search). */ - contentUrl(book, pathInZim = '') { - this.#assertRunning(); - return `${this.baseUrl}/content/${book}/${pathInZim}`; - } - /** Rebuilds library.xml and starts kiwix-serve. Resolves once the server is responding. */ async start() { - if (this.#child) return; + if (this.#remote || this.#child) return; await fs.promises.mkdir(this.#dir, {recursive: true}); await this.#rebuildLibrary(); this.#port ??= await findFreePort(); @@ -102,9 +107,9 @@ export class KiwixServer { } } - /** Gracefully stops kiwix-serve, if running. */ + /** Gracefully stops kiwix-serve, if we own it. No-op if attached to a remote instance. */ async stop() { - if (!this.#child) return; + if (this.#remote || !this.#child) return; const child = this.#child; await new Promise(resolve => { child.once('exit', resolve); @@ -118,16 +123,17 @@ export class KiwixServer { await this.start(); } - /** Rebuilds library.xml from disk and hot-reloads kiwix-serve via SIGHUP - no downtime/restart needed. */ + /** 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.#child) return; + 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 since these already live on disk. */ + /** Local catalog listing - same flat shape as the online catalog (catalog.js), plus a `file` field. */ async list() { this.#assertRunning(); - const xml = await fs.promises.readFile(this.#libraryPath, 'utf8').catch(() => ''); + const xml = await this.#fetchLibraryXml(); const entries = fromXml(xml); return (entries?.library?.book || []).map(e => { const tags = e.tags.split(';'); @@ -147,7 +153,7 @@ export class KiwixServer { articleCount: +e.articleCount || 0, sizeMb: +(Number(e.size) / 1024).toFixed(1) || 0, href: name, - icon: e.favicon, + icon: `data:${e.faviconMimetype || 'image/png'};base64,${e.favicon}`, viewer: `${this.baseUrl}/content/${name}`, }; }); @@ -169,8 +175,8 @@ export class KiwixServer { /** Fetches a single asset's raw bytes straight from kiwix-serve. */ async raw(href) { - const res = await fetch(this.fetch(href)); - if (!res.ok) return null; + 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())}; }