On this page
Building a Boundless source
What a Boundless extension is, where it runs, and the sandbox rules that decide whether it works on a real device.
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.
Only build a source for content you are allowed to access. Write one for a site you run yourself, for a site whose operator has given you permission, or for public-domain and openly licensed material. A source runs on every user's device and reaches its target over their connection, so what it fetches matters to them as well as to you. Do not build a source that gets around a login, a paywall, rate limits, bot checks or copy protection, and do not point one at material you have no right to read or distribute. You are responsible for the sources you write and the repositories you publish. Boundless does not host, vet, or endorse third party sources.
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 new module.exports.Source(). That is the only shape it tries; export anything else and construction throws before the required-methods check ever runs.
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 on the user's device, embedded in the app, in a sandboxed JavaScript runtime. 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 the sandbox. 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 the sandbox does not have it, and Boundless does not shim it in. Every extension in the official repository builds query strings with a local helper instead, 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),
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.