This is the complete contract between the app and an extension, written against the code that actually performs the conversion. The host reads a fixed set of field names off the plain objects you return and ignores everything else, so a misspelled key produces a blank value in the UI rather than an error. Where two names are listed for one field, the host checks them in the order given and takes the first that is present.
Loading and export
The host evaluates your file, then tries to construct a source with no arguments, in this order:
new module.exports.Source()new module.exports['<source-id>']()where the id is the folder name in the repositorynew Source()as a global
module and exports are pre-defined globals, so module.exports = { Source }; at the end of the file is the normal shape. Methods are invoked on the instance, so this works and helper methods on the class are available.
Host globals
| Global | What it is |
|---|---|
App.createRequest(o) |
Returns o unchanged. Use it for readability, or pass a plain object literal. |
App.createRequestManager(opts) |
Returns { schedule(request) }. opts is ignored. |
App.createSourceStateManager() |
An in-memory { retrieve, store, keychain } store. Not persisted across app launches. |
App.createSourceManga, createMangaInfo, createChapter, createChapterDetails, createPagedResults, createPartialSourceManga |
Identity functions, present for compatibility. They return their input. |
console.log/info/warn/error/debug |
Writes to the device log, tagged [ext:<source-id>]. |
URLSearchParams |
A host-installed shim. See the overview for the caveat. |
Networking
const manager = App.createRequestManager({});
const response = await manager.schedule(
App.createRequest({
url: 'https://example.com/api?page=1', // required, http or https only
method: 'GET', // defaults to GET
headers: { 'User-Agent': UA }, // string values only
data: undefined, // request body, as a string
})
);
schedule resolves with:
| Field | Type | Notes |
|---|---|---|
status |
number | HTTP status code. Non-2xx resolves normally, it does not reject. |
headers |
object | Response headers as strings. |
data |
string | The response body. Never parsed for you. |
Transport failures (DNS, TLS, timeout) reject with a plain string message, not an Error object. A second argument to schedule is accepted and ignored, so any retry logic must be your own.
getSourceFeeds()
Optional. Synchronous, called once at load time on the JavaScript queue, so it must return immediately: no await, no network.
getSourceFeeds() {
return [
{ id: 'popular', name: 'Popular' },
{ id: 'latest', name: 'Latest' },
];
}
Each entry needs both id and name as strings, and entries missing either are dropped. If the method is absent or the list ends up empty, the app falls back to one feed: { id: 'popular', name: 'Popular' }. The selected feed id arrives as request.feed in getSearchResults.
getSearchTags()
Optional and async. Populates the genre picker.
async getSearchTags() {
return [{ id: 'action', label: 'Action' }];
}
id is required and must be a string. The display name is read from label, then name, and falls back to the id. If the method is not defined, the app uses an empty list and hides the picker. Selected tags arrive as request.includedTags.
getSearchResults(request, metadata)
Async. Powers both Browse and Search.
The request object
| Field | Type | Notes |
|---|---|---|
title |
string | The search query. '' when browsing. |
feed |
string | The selected getSourceFeeds() id. Present only when browsing, absent during search. |
includedTags |
array | [{ id: 'action' }]. Always present, often empty. Objects, not bare strings. |
excludedTags |
array | Always present, always empty today. |
medium |
string | 'comics' or 'novel', present only when the app's medium filter is set. |
The metadata argument
The host builds this itself from its own page counter. It is null on the first page and { page: n } for page n where n >= 2. It does not echo back the object you returned last call, so extra fields you put in your returned metadata will not come back to you. Track nothing but the page number.
The return shape
return { results: [ /* partial manga */ ], metadata: { page: page + 1 } };
A bare array is also accepted, but it can never advertise a next page. The host offers another page only when the returned object has a metadata property that is not undefined and results is non-empty. Omit metadata on the last page. Returning it forever makes Browse re-fetch the final page indefinitely.
Manga fields
Read from each entry in results:
| Field | Type | Notes |
|---|---|---|
mangaId or id |
string | Required. Must be a string. A numeric id is dropped, so stringify it. |
title, or titles[0] |
string | Falls back to "Untitled". |
image |
string | Cover URL. Must be absolute. Not coverURL. |
author |
string | |
summary or description |
string | In list results only. Details use desc, see below. |
tags or genres |
array of strings | Bare strings here, not objects. |
webURL |
string | The series page on the source site, used by the in-app browser button. |
medium |
string | 'novel' selects the prose reader. Anything else is treated as comics. Overridden by the app's medium filter when one is set. |
rating |
number | |
views |
number | Whole numbers. |
chapters |
number | Chapter count for display. Whole numbers. |
completed |
boolean | Defaults to false. |
publisher |
string | |
releaseDate |
string | Kept and displayed as a string, not parsed. |
publishingStatus |
string | Free-form label, separate from status. |
getMangaDetails(mangaId)
Async. Returns one object. Display fields may be nested under mangaInfo or placed flat at the top level, and both are read the same way:
return {
mangaInfo: {
title: 'Title',
image: 'https://example.com/cover.jpg',
author: 'Author',
desc: 'Long summary text.',
tags: ['Action'],
status: 'ONGOING',
medium: 'comics',
webURL: 'https://example.com/series/slug',
},
};
Every field from the manga table above applies here, with two differences:
- The summary is read from
descfirst, thendescription.summaryis not read on this path, which is a common source of an empty detail page on a source whose list results look fine. statusis read only here. The value is uppercased and matched exactly.
Returned status |
Shown as |
|---|---|
ONGOING |
Ongoing |
COMPLETED, ENDED |
Completed |
HIATUS |
Hiatus |
ABANDONED, CANCELLED |
Cancelled |
| anything else, or absent | Unknown |
The id is not read from your response. The host uses the mangaId it passed in.
getChapters(mangaId)
Async. Returns a flat array. The host sorts it by number descending, so ordering on your side is not required, but the numbers must be right.
| Field | Type | Notes |
|---|---|---|
id or chapterId |
string | Required. Entries without one are dropped silently. |
name or title |
string | Display title. |
number or chapNum |
number | Defaults to 0. Used for sorting and for the "Chapter n" label. |
group |
string | Scanlator or translator credit. |
time |
number | Upload date as epoch milliseconds. Not a Date, not an ISO string. |
time must be numeric. The host treats a value above 1e11 as milliseconds and anything smaller as seconds, so a modern date in seconds still works, but milliseconds is the contract. A Date object or a string is ignored and the chapter shows no date. volume and pageCount are not read on this path.
Return the whole list. There is no paging on this method.
getChapterDetails(mangaId, chapterId)
Async. One method serves both comics and prose, and the app calls only the branch it needs based on the series medium.
Comics:
return {
id: chapterId,
mangaId,
pages: [
'https://cdn.example.com/1.jpg',
'https://cdn.example.com/2.jpg',
],
referer: 'https://example.com/',
};
pages must be an array of absolute URL strings. Objects with a url property are not accepted, and any entry that does not parse as a URL is dropped. Page order is the array order.
referer is optional and exists for hotlink protection: some page-image CDNs return 403 unless the request carries a Referer matching the source's own site. Set it to the site root and the app attaches that header to every page image request for the chapter. Leave it out entirely when the CDN does not need it, and never set it to an empty string.
Prose:
return { id: chapterId, mangaId, pages: [], text: chapterBody };
The chapter body is read from text, then body. Plain text with newlines is what the prose reader expects. Return pages: [] alongside it so an image reader never receives URLs it cannot display.
An empty string in text is treated as absent, and so is a missing property, so a chapter you cannot resolve should return no text rather than a placeholder.