Boundless ships with no sources inside the app binary. Everything the app can browse or read comes from an extension: a single JavaScript file, hosted as a static file on the web, installed by the user from a repository URL, and executed on their device. This page covers what that file looks like, where it runs, how a request travels from a tap in the app to the site and back, and the sandbox rules that trip up most first attempts.
An extension is one file
An extension is one index.js that defines a class called Source and exports it:
class Source {
async getSearchResults(request, metadata) { /* ... */ }
async getMangaDetails(mangaId) { /* ... */ }
async getChapters(mangaId) { /* ... */ }
async getChapterDetails(mangaId, chapterId) { /* ... */ }
}
module.exports = { Source };
The host loads your file, then constructs the source with no arguments. It tries three export shapes in order: new module.exports.Source(), then new module.exports['<your-source-id>'](), then a global new Source(). The first one that constructs wins. If none do, the install fails with "extension did not export a Source".
Four methods are required (getSearchResults, getMangaDetails, getChapters, getChapterDetails) and two are optional (getSourceFeeds for the Browse tabs, getSearchTags for the genre picker). Exact arguments and return shapes are in the API reference.
Where it runs
Your code runs in JavaScriptCore, embedded in the app on the user's device. That is not a browser tab and not Node. There is no bundler step, no npm install, no transpiler: the exact file you publish is the file that gets evaluated, so it must be plain, self-contained JavaScript with no imports.
What you do get is the full modern ECMAScript standard library: classes, async/await, Promise, JSON, RegExp, Date, Math, Intl, spread and optional chaining, String.prototype.replaceAll, Object.fromEntries, encodeURIComponent, and so on.
What is not available
None of the following exist in JavaScriptCore. Reaching for one does not fail at install time. It throws a ReferenceError the moment that line runs, and the host logs it and hands the app nothing, so the symptom a user sees is a blank Browse tab rather than an error message. That makes these the most expensive mistakes to debug.
| Not available | Use instead |
|---|---|
fetch, XMLHttpRequest |
App.createRequestManager().schedule(request) |
URL |
plain string concatenation |
setTimeout, setInterval, queueMicrotask |
no timers at all; use promises directly |
document, DOMParser, any DOM API |
run regexes over the HTML string |
require, import |
nothing to import; write one self-contained file |
Node built-ins (fs, path, crypto, Buffer, process) |
not present, and there is no filesystem |
TextDecoder, atob, btoa, structuredClone |
JSON.parse(JSON.stringify(x)) for the last one |
One special case: URLSearchParams is a browser API, not an ECMAScript one, so bare JavaScriptCore does not have it either. Because so much existing extension code reaches for it, the Boundless host installs a small compatibility shim before your script is evaluated, so it does work on device. Every extension in the official repository still builds query strings with a local helper, which keeps the file runnable in any harness:
function qs(params) {
return Object.keys(params)
.filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== '')
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(String(params[k]))}`)
.join('&');
}
console.log, console.warn and console.error are available and go to the device log, tagged [ext:<source-id>].
The request flow
- The user opens Browse. The app calls
getSearchResults(request, metadata)on your source. - Your method builds a URL string and calls
App.createRequestManager().schedule(request). - The host performs that request natively through the app's own URLSession, then resolves the promise with
{ status, headers, data }. - You parse
data(always a string, never pre-parsed JSON) and return plain JavaScript objects. - The host reads specific field names off those objects and maps them onto its own models. A field name it does not recognise is silently ignored.
Consequences worth internalising before you write anything:
- All networking is native. The TLS stack, cookie storage, HTTP cache and default
User-Agentbelong to the app, not to your code. Requests are limited tohttpandhttpsURLs, redirects are followed automatically, and each request times out after 25 seconds. - A non-2xx response does not reject. The promise resolves with whatever status came back, so check
response.statusyourself. Only transport failures reject, and they reject with a plain string message rather than anError. response.datais a string. CallJSON.parseyourself.- Wrong field names fail quietly. Returning
coverURLinstead ofimageproduces a card with no cover, not an exception.
Hello world
A complete, working extension. It browses Gutendex, the public JSON API over the Project Gutenberg catalogue, and serves each book as a single prose chapter.
const API = 'https://gutendex.com';
function qs(params) {
return Object.keys(params)
.filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== '')
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(String(params[k]))}`)
.join('&');
}
function findFormat(formats, prefix) {
const key = Object.keys(formats || {}).find((k) => k.indexOf(prefix) === 0);
return key ? formats[key] : undefined;
}
async function get(url, accept) {
const manager = App.createRequestManager({});
const response = await manager.schedule(
App.createRequest({ url, method: 'GET', headers: { Accept: accept } })
);
if (response.status < 200 || response.status >= 300) {
throw new Error(`HTTP ${response.status}`);
}
return response.data;
}
function toManga(book) {
return {
mangaId: String(book.id),
title: book.title || 'Untitled',
image: findFormat(book.formats, 'image/'),
author: (book.authors && book.authors[0] && book.authors[0].name) || undefined,
tags: (book.subjects || []).slice(0, 8),
webURL: `https://www.gutenberg.org/ebooks/${book.id}`,
medium: 'novel',
completed: true,
};
}
class Source {
getSourceFeeds() {
return [{ id: 'popular', name: 'Popular' }];
}
async getSearchResults(request, metadata) {
const page = (metadata && metadata.page) || 1;
const params = { page };
if (request && request.title) params.search = request.title;
const data = JSON.parse(await get(`${API}/books/?${qs(params)}`, 'application/json'));
const results = (data.results || []).filter((b) => b.copyright === false).map(toManga);
return { results, metadata: data.next ? { page: page + 1 } : undefined };
}
async getMangaDetails(mangaId) {
const book = JSON.parse(await get(`${API}/books/${encodeURIComponent(mangaId)}/`, 'application/json'));
return { mangaInfo: { ...toManga(book), desc: (book.summaries || [])[0] || '', status: 'COMPLETED' } };
}
async getChapters(mangaId) {
return [{ id: 'full', name: 'Full Text', number: 1, time: Date.now() }];
}
async getChapterDetails(mangaId, chapterId) {
const book = JSON.parse(await get(`${API}/books/${encodeURIComponent(mangaId)}/`, 'application/json'));
const textUrl = findFormat(book.formats, 'text/plain');
if (!textUrl) return { id: chapterId, mangaId, pages: [] };
return { id: chapterId, mangaId, pages: [], text: await get(textUrl, 'text/plain') };
}
}
module.exports = { Source };
Two details in there matter more than they look. mangaId is stringified, because the host reads it as a string and a raw number is dropped. And metadata becomes undefined on the last page, which is the only signal the app has to stop infinite scroll.
Next steps
Read the API reference for the exact field names, then test it properly before you publish it to a repository.