Developer documentation

NovelReader extension repositories

Extensions let NovelReader connect to personal servers, public catalogs, and supported source systems without baking every source into the app. This guide explains how to build, test, package, and publish extensions at a high level of detail.

System model

A NovelReader extension is a bundled JavaScript module that runs inside the app's extension sandbox. The app host owns installation, settings UI, connection records, credentials, networking, download mediation, and capability approval. The extension owns source- specific parsing and maps a remote catalog or server into the NovelReader extension API.

Build extensions for user-owned libraries, personal servers, public-domain catalogs, licensed catalogs, or other integrations the user is authorized to access. Do not present extensions as a way to bypass copyright or access restrictions.

Repository format

An extension repository is a static HTTPS directory that exposes an index.json file. NovelReader fetches {repoUrl}/index.json, reads each entry, and resolves relative bundle and icon paths against the repository URL.

{
  "extensions": [
    {
      "id": "server-example",
      "name": "Example Server",
      "description": "Connect to an Example Server library",
      "version": "1.0.0",
      "type": "server",
      "protocolVersion": 1,
      "bundleUrl": "server-example/",
      "icon": "server-example/icon.png"
    }
  ]
}
Field Required Meaning
id Yes Stable kebab-case extension id. It must match the extension manifest id.
name Yes Display name shown in the extension browser.
description Yes Short description shown in the extension browser. May be an empty string.
type Yes Extension kind, matching the manifest type (currently server or source).
version Yes Semantic version used for update checks.
protocolVersion Yes Extension host protocol version. Current supported value is 1.
bundleUrl Yes Absolute URL or repository-relative directory containing manifest.json and the entry bundle.
icon No Absolute URL or repository-relative icon path.

The default NovelReader production repository is https://mozsoftwarellc.github.io/extensions/. In development, the app defaults to http://localhost:5111/repo/.

Extension package structure

First-party extensions in this repo live under extensions/*. A new extension should follow the same shape so it can be built by the existing extension build script.

extensions/example-server/
  manifest.json
  package.json
  tsconfig.json
  icon.png
  src/
    index.ts
  __tests__/
    index.test.ts
  • manifest.json declares identity, settings, capabilities, auth, network policy, icon, and bundle entry point.
  • src/index.ts implements the extension contract and registers window.__extensionHandler.
  • package.json should depend on @novelreader/extension-api as a workspace development dependency.
  • tsconfig.json should use strict TypeScript and target browser-compatible JavaScript.

Manifest contract

The manifest is validated before build and installation. Invalid manifests are rejected early to prevent unsafe or ambiguous extension behavior.

{
  "id": "server-example",
  "name": "Example Server",
  "description": "Connect to your Example Server",
  "version": "1.0.0",
  "protocolVersion": 1,
  "type": "server",
  "serverType": "example",
  "capabilities": ["network:server-url", "storage:settings"],
  "entryPoint": "extension.js",
  "icon": "icon.png",
  "settings": [
    {
      "key": "serverUrl",
      "type": "text",
      "label": "Server URL"
    },
    {
      "key": "apiKey",
      "type": "password",
      "label": "API Key"
    }
  ]
}
  • id must be kebab-case, for example server-kavita.
  • version must be a semantic version.
  • protocolVersion must be 1 for the current app host.
  • entryPoint is usually extension.js, the bundled file generated by the build script.
  • settings may use text, password, toggle, or select. A select setting needs a string options array; any setting may declare a defaultValue.
  • Settings marked "localOnly": true are stored only on the device, like secrets. password settings and keys ending in apiKey, token, secret, or password are treated as device-local automatically.
  • icon must be a relative bundle asset path, not an absolute URL or path traversal.

Capabilities

Capabilities are part of the user trust model. Declare only what the extension needs. If an update adds capabilities, NovelReader can require the user to review the new request before updating.

Capability Use it when
network:server-url The extension connects to a user-provided server or feed URL from settings.
network:allowed-hosts The extension needs to request fixed external hosts outside the configured server URL.
network:external-downloads The extension returns download URLs that may resolve outside the configured server host.
storage:settings The extension needs manifest-backed user settings.
auth:web-login The extension uses hosted web login with host-managed cookies or token capture.

Bridge and runtime contract

The extension bundle must register window.__extensionHandler. The host sends bridge messages by method name; the handler routes them to the extension implementation and returns serializable data.

import type {
  ExtensionConfig,
  ReadProgress,
  ServerExtension,
} from "@novelreader/extension-api";

const extension: ServerExtension = {
  id: "server-example",
  name: "Example Server",
  version: "1.0.0",
  type: "server",
  serverType: "example",

  async activate(config: ExtensionConfig) {
    // Read config.settings and prepare connection state.
  },

  async deactivate() {
    // Clear in-memory state. Do not delete user settings.
  },

  async fetchListing(page = 1) {
    return { items: [], page, totalPages: 1, hasNextPage: false };
  },

  async search(query: string) {
    return [];
  },

  async fetchChapters(itemId: string) {
    return [];
  },

  getSettings() {
    return {};
  },

  async testConnection() {
    return true;
  },

  async getLibraries() {
    return [];
  },

  async getSeries(libraryId, filters) {
    return { items: [], page: filters?.page ?? 1, totalPages: 1, hasNextPage: false };
  },

  getPageUrl(chapterId, page) {
    return "";
  },

  async getDownloadUrl(chapterId) {
    return "";
  },
};

(window as any).__extensionHandler = async (message: {
  method: string;
  params?: unknown;
}) => {
  const params = message.params && typeof message.params === "object"
    ? (message.params as Record<string, unknown>)
    : {};

  switch (message.method) {
    case "activate":
      return extension.activate(params as unknown as ExtensionConfig);
    case "deactivate":
      return extension.deactivate();
    case "fetchListing":
      return extension.fetchListing(typeof params.page === "number" ? params.page : undefined);
    case "search":
      return extension.search(typeof params.query === "string" ? params.query : "");
    case "fetchChapters":
      return extension.fetchChapters(typeof params.itemId === "string" ? params.itemId : "");
    case "getSettings":
      return extension.getSettings();
    case "testConnection":
      return extension.testConnection();
    case "getLibraries":
      return extension.getLibraries();
    case "getSeries":
      return extension.getSeries(
        typeof params.libraryId === "string" ? params.libraryId : "",
        params.filters as any,
      );
    case "getPageUrl":
      return extension.getPageUrl(
        typeof params.chapterId === "string" ? params.chapterId : "",
        typeof params.page === "number" ? params.page : 1,
      );
    case "getDownloadUrl":
      return extension.getDownloadUrl(typeof params.chapterId === "string" ? params.chapterId : "");
    case "getReadProgress":
      return extension.getReadProgress?.(
        typeof params.chapterId === "string" ? params.chapterId : "",
      );
    case "updateReadProgress":
      return extension.updateReadProgress?.(
        typeof params.chapterId === "string" ? params.chapterId : "",
        params.progress as ReadProgress,
      );
    default:
      throw new Error(`Unknown method: ${message.method}`);
  }
};

Server extensions may also implement getReadProgress and updateReadProgress for server-side progress sync. OPDS-style catalogs usually cannot sync progress, so those methods should be omitted when unsupported. Route every method the host may send, including getPageUrl and the optional progress methods — an unrouted method surfaces to the host as an error.

Host bridge: calling the host from the extension

The bridge is bidirectional. The host injects window.bridge into the sandbox, and window.bridge.request(method, params) is how an extension reaches host services. The sandbox does not provide direct network access, so the host fetch method is the only way to make HTTP requests.

declare const window: {
  bridge: {
    request(method: string, params?: unknown): Promise<unknown>;
  };
};
Host method Required capability Behavior
fetch network:server-url Proxied HTTP request. The host resolves the URL, injects credentials, and enforces network policy.
getSettings storage:settings Returns the user's current values for the manifest-declared settings.
updateSettings storage:settings Persists a settings object for the extension.
debugLog None Writes { scope, message, data } to the host debug log with credential values redacted.

Proxied fetch

Pass server-relative paths such as /api/series. The host resolves them against the connection's configured server URL and attaches the right auth headers or credential query parameters, so extension code never handles secrets directly. Absolute URLs are rejected unless the extension declares network:allowed-hosts, the hostname is in network.allowedHosts, and the scheme is HTTPS.

type ProxiedFetchRequest = {
  url: string;                       // usually a server-relative path
  method?: string;                   // default GET
  headers?: Record<string, string>;
  body?: string;
  responseType?: "text" | "base64";  // base64 for binary payloads
};

type ProxiedFetchResponse = {
  status: number;
  statusText: string;
  headers: Record<string, string>;
  body: string;
  bodyEncoding?: "utf8" | "base64";
};

async function bridgeFetch(url: string, method = "GET", body?: string) {
  const params: Record<string, unknown> = { url, method };
  if (body !== undefined) params.body = body;
  return (await window.bridge.request("fetch", params)) as ProxiedFetchResponse;
}

Bridge errors

Bridge failures carry an error code: NETWORK_ERROR, AUTH_ERROR, RATE_LIMITED, NOT_FOUND, INVALID_RESPONSE, TIMEOUT, PERMISSION_DENIED, or EXTENSION_CRASH. Calling a host method without its required capability fails with PERMISSION_DENIED.

Networking and auth

Extensions should not directly assume unrestricted network access. All requests go through the host fetch bridge method described in Host bridge, which enforces declared capabilities, server URL boundaries, allowed hosts, credential query parameter binding, and hosted-auth behavior.

Allowed hosts

Use network.allowedHosts only for exact external hostnames the extension must contact. Wildcards, paths, ports, and schemes are not valid in the host list.

{
  "capabilities": ["network:server-url", "network:allowed-hosts"],
  "network": {
    "allowedHosts": ["downloads.example-cdn.com", "mirror.example-cdn.com"]
  }
}

Credential query parameters

Some services need a password setting appended to specific asset or download URLs. Bind that behavior explicitly so the host can add the secret only on the intended path.

{
  "settings": [
    { "key": "apiKey", "type": "password", "label": "API Key" }
  ],
  "network": {
    "credentialQueryParams": [
      {
        "path": "/api/Reader/image",
        "settingKey": "apiKey",
        "param": "apiKey"
      }
    ]
  }
}

Hosted web login

Use auth:web-login when users authenticate through a web session. Cookie mode requires successCookieNames. Token mode requires tokenCapture.

{
  "capabilities": ["network:server-url", "auth:web-login"],
  "auth": {
    "kind": "web-login",
    "mode": "cookie",
    "supportsAnonymous": true,
    "loginPath": "/",
    "authCheckPath": "/account",
    "completionPathPrefixes": ["/account", "/profile"],
    "successCookieNames": ["session"],
    "logoutPath": "/account/logout",
    "signedOutHelpText": "Anonymous browsing is available. Sign in for account downloads.",
    "signedInHelpText": "Signed in on this device."
  }
}

Implementation pattern

  1. Create a stable id, for example server-example.
  2. Add an extension folder under extensions/ with a manifest, package file, TypeScript config, icon, source, and tests.
  3. Model the remote system as libraries, series, chapters, listings, and downloads using @novelreader/extension-api types.
  4. Normalize remote IDs to stable strings. Avoid using display names as IDs when the remote API provides durable identifiers.
  5. Return only EPUB/PDF downloads or clearly filter unsupported formats.
  6. Map errors to recoverable messages where possible: auth failure, network failure, invalid response, not found, and permission denied.
  7. Keep credentials in manifest settings or hosted-auth flow; do not hard-code secrets in extension source.
  8. Register every bridge method you implement and reject unknown methods.
First-party reference implementations currently include Kavita, Komga, OPDS, and Legal Catalog extensions under extensions/.

Testing

Extension tests should cover both source-specific parsing and the bridge contract. Use local fixtures for remote responses so tests do not depend on real servers.

  • Verify window.__extensionHandler is registered.
  • Test activate, testConnection, library/series browsing, search, chapter listing, and download URL behavior.
  • Test malformed responses, unsupported file formats, empty catalogs, authentication errors, and pagination.
  • Test that credential values do not appear in logs, returned metadata, or visible error strings.
  • Run focused extension tests with bun test extensions/<name> when possible.

Build and publish

NovelReader ships a workspace build flow for first-party extensions.

# Build the shared extension API contract
bun run build:extension-api

# Build all extensions into dist/extensions
bun run build:extensions

# Build with absolute repository URLs in index.json
EXTENSIONS_BASE_URL=https://example.com/extensions/ bun run build:extensions

# Serve built extensions locally
bun run serve:extensions

serve:extensions hosts dist/extensions at http://localhost:3001/. Development builds of the app look for the default repository at http://localhost:5111/repo/, so either serve the build at that address or add http://localhost:3001/ as a repository in the app's extension settings.

The build script discovers extension folders, validates manifests, bundles src/index.ts to extension.js, copies manifests and icons, and writes dist/extensions/index.json. Publish the contents of dist/extensions/ to a static host. The repository URL should be the directory containing index.json.

Treat repository URLs as a trust boundary. Users should understand who operates a repository before adding it, and extensions should request the narrowest capabilities that work.

Release checklist

  • Manifest validates and uses protocol version 1.
  • Capabilities are minimal and match manifest auth/network fields.
  • Settings labels are clear and password fields are marked password.
  • All HTTP requests go through window.bridge.request("fetch", ...) with server-relative paths where possible.
  • Remote network paths stay within server URL or exact allowed hosts.
  • Only EPUB/PDF downloads are returned to NovelReader.
  • Bridge methods return serializable data and reject unknown methods.
  • Tests cover success, pagination, auth failure, network failure, malformed data, and unsupported formats.
  • bun run build:extensions succeeds and generated index.json points at reachable bundles.