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
- Create a stable id, for example
server-example.
- Add an extension folder under
extensions/ with a manifest, package file, TypeScript config, icon, source, and tests.
- Model the remote system as libraries, series, chapters, listings, and downloads using
@novelreader/extension-api types.
- Normalize remote IDs to stable strings. Avoid using display names as IDs when the remote API provides durable identifiers.
- Return only EPUB/PDF downloads or clearly filter unsupported formats.
- Map errors to recoverable messages where possible: auth failure, network failure, invalid response, not found, and permission denied.
- Keep credentials in manifest settings or hosted-auth flow; do not hard-code secrets in extension source.
- 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.