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.

4 min read Updated 2 October 2026

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

  1. The user opens Browse. The app calls getSearchResults(request, metadata) on your source.
  2. Your method builds a URL string and calls App.createRequestManager().schedule(request).
  3. The host performs that request natively through the app's own URLSession, then resolves the promise with { status, headers, data }.
  4. You parse data (always a string, never pre-parsed JSON) and return plain JavaScript objects.
  5. 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-Agent belong to the app, not to your code. Requests are limited to http and https URLs, 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.status yourself. Only transport failures reject, and they reject with a plain string message rather than an Error.
  • response.data is a string. Call JSON.parse yourself.
  • Wrong field names fail quietly. Returning coverURL instead of image produces 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.