A configurable, content-neutral bridge runtime for serialized content (comics, manga, books, and the like). The same TypeScript core runs on desktop, web, iOS, and Android. Connection behaviour is supplied by swappable bridges — each bridge talks to a user-supplied backend such as a self-hosted Komga, Kavita, or OPDS library, or any site the user is authorized to use.
Legal posture: Comical ships no content and no backends, and operates no central bridge registry. It is infrastructure in the same category as a web browser or an official Komga client. You bring your own bridge and your own backend.
- Vocabulary
- Design goals
- Architecture
- Package reference
- Platform support
- Quick start
- Writing a bridge
- Registry
- Testing
- Development
| Term | Meaning |
|---|---|
| Runtime | The content-neutral engine — @comical/core. Ships no backends, no content. |
| Bridge | A TypeScript/JS plugin that implements the Bridge interface, translating one backend's API or HTML into the neutral data model. What Tachiyomi/Paperback call a "source." |
| Backend | The user-supplied server or site a bridge talks to — a Komga instance, a Kavita library, an OPDS feed, or a site the user is authorized to use. URL and credentials are always user-configured, never baked into a bridge. |
| Host adapter | The per-platform layer that provides real capabilities (HTTP, storage, logging) to the core. One adapter per runtime environment. |
| Host capability API | The interface a host adapter implements (network.request, storage, log). The only contact surface between the sandbox and the outside world. |
| Registry | An optional, user-added catalog of bridges (index.json + bundle files on any static host). The project operates none. |
These are the load-bearing constraints behind every design decision in the core. They are not aspirational — they are the rules a change has to satisfy to be correct.
The same TypeScript core (@comical/core + @comical/contract) is meant to run unchanged on
desktop, web, iOS, and Android. This is a fundamental goal, not an afterthought, so it constrains
what may live in the core:
- The core never imports a platform API (
fs,process, sockets,fetch, the DOM). Platform access is reached only through the host capability API. - The contract (
@comical/contract) is the single stable boundary. Everything a client needs to act is expressed there as plain, zod-validated data — never as platform-specific code or pre-rendered UI. - Presentation is data, never bridge- or host-rendered UI. If a feature would otherwise force a client to run bespoke per-platform logic, the necessary information is pushed into the contract as a typed value so each client renders it with its own native primitives.
Worked example — page thumbnails.
Page.thumbnailis a typed descriptor: either{ kind: "image", url }or{ kind: "sprite", sheetUrl, x, y, w, h, sheetWidth }(slice metadata for a tile inside a shared sprite sheet). The host does no image processing. Web renders a sprite as an inline SVGviewBox; Android usesBitmapRegionDecoder; iOS usesCGImage(cropping:). One contract shape, each platform slices the way it's good at — and the bytes are never recompressed.
Adopting Comical inside an application should be a small integration, not a port. A new host implements only:
HostCapabilities—network.request,storage,log. That's the whole surface.- A
BundleEvaluator— run a CJS bundle in your platform's JS engine (JSC, QuickJS, V8, …).
Everything else — loading, sandboxing, rate-limiting, response caching, cookie sessions, contract validation, settings enforcement, the registry — is provided by the core and shared across every platform. A host must never be asked to re-implement product logic (parsing, slicing, ranking, rendering) that the core or contract can carry for it. When in doubt, the lighter the host, the more correct the design.
┌──────────────────────────────────────────────────────────────────────┐
│ Bridges (TS/JS CJS bundles) │
│ example-bridge · komga-bridge · opds-bridge · … │
│ authored with @comical/sdk · user-supplied · hot-updatable │
├──────────────────────────────────────────────────────────────────────┤
│ Bridge Contract @comical/contract │
│ versioned TS interfaces + zod schemas · the stable boundary │
├──────────────────────────────────────────────────────────────────────┤
│ Core Runtime @comical/core │
│ loader · sandbox · BundleEvaluator · capability gate │
│ rate-limiter · response cache · zod boundary validation │
├──────────────────────────────────────────────────────────────────────┤
│ Host Adapters (one per platform) │
│ host-bun · host-server · host-rn · host-ios · host-android │
│ implement HostCapabilities: network · storage · log │
└──────────────────────────────────────────────────────────────────────┘
The core never imports platform APIs (fs, process, sockets). It only calls the host
capability API, which each host adapter satisfies. Porting Comical to a new platform means
writing one adapter — nothing else changes.
Every bridge default-exports a factory (host: HostCapabilities) => Bridge. The Bridge
interface (from @comical/contract) defines:
| Method | Required | Capability | Description |
|---|---|---|---|
getSeriesDetails(seriesId) |
✓ | — | Full metadata for a series |
getChapters(seriesId) |
✓ | — | Ordered chapter list |
getChapterPages(seriesId, chapterId) |
✓ | — | Absolute image URLs for a chapter |
getLists(query?) |
optional | lists |
The bridge's self-defined browsable collections |
getListItems(listId, page) |
optional | lists |
Paginated entries within a list |
getSearchResults(query, page, filters?) |
optional | search |
Paginated text search |
getFilters() |
optional | filters |
Declarative search filter descriptors |
getTags() |
optional | tags |
Tag/genre catalog |
getSettings() |
optional | settings |
Declarative settings (backend URL, credentials) |
loadSettings() / runSettingAction(key) |
optional | — | Live settings form + action buttons (contract 2.1) |
Only the read path (details/chapters/pages) is mandatory. Browse and search are separate optional capabilities — a bridge may offer lists, search, both, or neither.
Core data models: SeriesEntry, SeriesInfo, Chapter, Page, PagedResults<T>,
SeriesList, Filter, BridgeInfo. All are zod-validated at the bridge boundary — a
misbehaving bridge cannot inject malformed data into the host.
Lists, not prescribed sections. Rather than hardcoded popular/latest/home methods, a
bridge declares its own lists via getLists() (Trending, Recently Updated, a genre, Staff
Picks, …). Each SeriesList is { id, name, description?, layout?, featured?, searchable? } where
layout is carousel | grid | ranked | hero. Home becomes "render the featured lists" —
presentation is always data, never bridge-rendered UI, keeping bridges portable across headless
and UI environments.
Searching within a list. getListItems(listId, page, options?) takes a ListOptions bag —
{ query?, filters?, sort? } — so a host can search/filter/sort inside one list. A list opts in
by setting searchable: true (the query is only honored for those); a list whose backend can't
be queried (e.g. an infinite "trending" feed) simply omits the flag and ignores options. Over
REST it's ?q=&filters=&sort=&dir= on the list-items route; via the CLI, comical lists <listId> --query Q [--filter k=v] [--sort key].
Image requests. A bridge emits the source's real image URLs and declares, in
BridgeInfo.images, which hosts they live on and what fetching them takes:
images: {
hosts: ["cdn.example.com"], // the only hosts the host will fetch for this bridge
headers: { Referer: "https://example.com/" }, // sent with every image on those hosts
intercept: ["cdn.example.com"], // hosts whose URLs go through interceptImageRequest
}interceptImageRequest(url) → { url, headers? } runs at fetch time, every time, and its result
is never stored — so it is where anything that expires belongs (a rotating CDN path, a signed URL,
a viewer page to look up). The core rewrites an image URL that needs headers or interception into
the host's /bridges/:id/image?url=<original> route; every other URL reaches the client untouched.
The route redirects when the resolved request needs no headers and proxies when it does, refuses
any URL (original or intercepted) off images.hosts, and maps a rateLimitedMessage thrown by the
interceptor to a 429. images supersedes assetProxy and resolvePage, which keep working.
Links. A bridge that can open web links to its source declares the sites in
BridgeInfo.linkHosts (each matching its subdomains too) and implements
resolveUrl(url) → { seriesId, chapterId? } | null, returning null for a page that isn't a series
or chapter. The host's GET /resolve-link?url= asks only the bridges whose linkHosts cover the
link, in order, and answers { bridgeId, seriesId, chapterId? } — or 404 with a reason:
no-bridge (nothing installed covers the site) or not-recognized (one does, but not that page).
Parse the URL rather than fetch it where you can; a user is waiting on the answer.
Id migrations. A release that changes the ids a bridge gives out sets BridgeInfo.migratesIds
and implements migrateIds({ fromVersion, series: [{ id, chapterIds }] }) → { series: [{ id, newId?, chapters?: [{ id, newId }] }], failed? }, listing only what changed. On each device, the first load
of that version (runtime withIdMigration, which host-server and host-rn wrap their providers in)
holds the bridge back, asks it — in batches — about every id the library and downloads hold, and
moves those records onto the answer: collected items, progress, history, activity, groups, tracker
links, the offline caches and the download manifest (page files stay where they are). A per-device
mark records the version once the move is complete; a series in failed, a throw, or a chapter
mid-download leaves it unset, so the next load asks again. The answer must be idempotent:
fromVersion is null on a device that never ran one, and an interrupted run is repeated. Records
another device already moved arrive through sync and merge with this device's own (progress only
ever moves forward), so nothing read is un-read.
Settings are declared by value kind, not UI widget — getSettings() returns
SettingDescriptor[]:
type |
Value | Extras |
|---|---|---|
string |
string |
secret? (API key / token / cookie / password — masked), placeholder?, default? |
number |
number |
min?, max?, default? |
boolean |
boolean |
default? |
enum |
one of options[].value (or string[] if multiple) |
options: {value,label}[], default? |
web-login (2.1) |
string — a Cookie header, "a=1; b=2" (secret) |
url (the site's sign-in page), cookies: string[] (names that mean signed in), cookieUrls? |
All share key, label, description?, required?. There is no password type — any string
sets secret: true; the host masks it. An enum carries the expected-values list so a UI can
render a picker.
Strict typing: wrap descriptors in defineSettings([...]) and InferSettings<typeof SETTINGS>
to derive the typed settings shape, then extends BridgeBase<Settings> — this.settings.baseUrl
is string, an enum is its literal union, required vs optional is enforced.
Host enforcement: the runtime applies declared defaults, coerces simple inputs
("40"→40, "true"→true), and validates values (type + enum membership). A submitted invalid
value is rejected — PUT /bridges/:id/settings answers unknown keys / wrong types with 400. A
stored value the installed version no longer accepts (an enum option a bridge update removed, a
changed range) is set aside on load and logged by key: the setting falls back to its default (or
counts as unset), so an update or reinstall never fails to load over an old choice. The host won't
dispatch content calls to a bridge whose required settings are unset (GET /bridges/:id
reports missingRequired).
Live form (contract 2.1). getSettings() is static and stays the source of truth for what can
be stored. A bridge may also implement loadSettings() → { sections: [{ title?, footer?, items }] }
for how the screen should look now — grouped, with labels or options looked up from the site, and
{ type: "action", key, label } buttons that call runSettingAction(key) → { ok, message? }
("Test login"). The form presents the declared settings and nothing else: the loader rebuilds each
value item from the descriptor of the same key and type, keeping only its label, description,
placeholder and (a subset of) enum options, drops anything else, and puts back a required setting
the form left out. GET /bridges/:id reports settingsForm: true when a bridge has one; the form
is GET /bridges/:id/settings/form and an action is POST /bridges/:id/settings/actions/:key,
which runs against the saved settings. A client falls back to settings if the form fails.
Web sign-in (contract 2.1). For a site with no API to sign in through, a web-login setting has
the user sign in on the site itself: the app opens url in a web view (an Electron window on
desktop), waits until every name in cookies is set, and stores the cookies it collected — from
url and each of cookieUrls — as the setting's value. A browser page can't read another site's
cookies, so there the user pastes the same string. When the site turns a request away for want of
a session, the bridge throws loginRequiredMessage(key, reason?); the router answers 401 { error, loginRequired: { key, reason } } and the app offers to open that setting.
Filters and sort are distinct concerns — filters narrow a result set, sort orders it —
and a search receives both in an options bag: getSearchResults(query, page, { filters?, sort? }).
A bridge with the filters capability advertises filters via getFilters():
Filter.type |
FilterValue.value |
|---|---|
text |
string |
toggle |
boolean |
number (min?, max?) |
number |
select (options[]) |
string (one option value) |
multiselect (options[]) |
string[] |
FilterValue is { key, value }; options are { value, label } (same shape as settings enum).
A bridge with the sort capability advertises sort fields via getSortOptions() →
SortOption[] ({ key, label }); the search receives a SortSelection ({ key, ascending }).
The core validates both inputs at the boundary. Over REST:
GET /bridges/:id/search?filters=<url-encoded JSON FilterValue[]>&sort=<key>&dir=asc|desc, with
GET /bridges/:id/filters and GET /bridges/:id/sort returning the descriptors. Via the CLI:
--filter key=value (repeatable; comma → string[]) and --sort key [--desc].
A bridge with the favorites capability syncs with the backend's own account bookmarks/follows
(not a local library). Three methods — getFavorites(page) (the minimum), plus the optional mutations
addFavorite(seriesId) / removeFavorite(seriesId). These are the contract's only write methods.
Favorites need auth, but browsing is anonymous — so auth is not all-or-nothing gating. The
bridge declares optional secret settings (a token, or a username + password) and the favorites
methods throw a clear error when they're absent; everything else keeps working logged-out. (No OAuth.)
Cookie sessions are a core concern. A bridge can POST a login and the runtime holds the session
for it: core's gated network keeps a per-bridge cookie jar (attaches Cookie, stores
Set-Cookie), and hosts merely report Set-Cookie via HttpResponse.setCookies. So a
login-then-reuse flow works identically on every host that runs core (token/bearer auth needs no jar —
the bridge keeps the token in host.storage).
Over REST: GET /bridges/:id/favorites?page=, PUT /bridges/:id/favorites/:seriesId (add),
DELETE /bridges/:id/favorites/:seriesId (remove). Via the CLI: comical favorites,
comical favorite <id>, comical unfavorite <id> (with --set sessionToken=…). The evaluator probes
getFavorites read-only and never auto-calls the mutations (they hit a real account).
The only things a bridge can do:
interface HostCapabilities {
network: {
// The sole HTTP path. Host applies rate-limiting, caching, cookies.
request(req: HttpRequest): Promise<HttpResponse>;
};
storage: {
// Namespaced per-bridge key/value (tokens, ETags, session state).
get(key: string): Promise<string | undefined>;
set(key: string, value: string): Promise<void>;
delete(key: string): Promise<void>;
keys(): Promise<string[]>;
};
log: { debug | info | warn | error };
// User-supplied settings (backend URL, credentials). Never baked into the bridge.
settings: Readonly<Record<string, string | boolean>>;
}Bridges never open sockets, read the filesystem, or see fetch, process, or require.
They receive a HostCapabilities object and work exclusively through it.
Bridge bundles are pre-compiled to self-contained CJS (bun build --format=cjs). Each
platform evaluates the bundle in isolation via a BundleEvaluator — the one platform-specific
seam inside the loader:
| Platform | Evaluator | Isolation |
|---|---|---|
| Bun/Node | NodeVmEvaluator (node:vm createContext) |
Cross-realm — require, process, fetch, Bun absent from the new realm |
| Browser | FunctionEvaluator (Function constructor + explicit global shadowing) |
Function-scope — fetch, XHR, WebSocket, localStorage, etc. passed as undefined |
| iOS | JSC JSContext (in host-ios) |
Cross-realm — fresh JSContext with only injected globals |
| Android | QuickJS runtime (in host-android) |
Cross-realm — fresh QuickJS context |
Swapping the evaluator is the only change needed to port the loader to a new engine.
The rate-limiter and response cache sit between the evaluator and the raw host network, so every bridge on every platform benefits uniformly without any per-bridge code.
| Deployment | How network.request is fulfilled |
|---|---|
host-bun (desktop/CLI) |
Native Bun fetch + cookie jar. Direct to backend, no infrastructure. |
host-server (LAN server) |
Native Bun fetch on the server. Browser/app calls the server's REST API instead of fetching directly. |
host-ios |
URLSession. Direct to backend. |
host-android |
OkHttp. Direct to backend. |
The recommended browser deployment is host-server — run comical serve on any machine
on your network and point your browser at http://host:3100. The browser never loads bridge
bundles or needs CORS at all.
| Package | Description |
|---|---|
@comical/contract |
Versioned Bridge interface, HostCapabilities, zod data models, contractVersion. The stable boundary — everything else builds on this. |
@comical/core |
Sandboxed bridge loader, BundleEvaluator interface, NodeVmEvaluator, gated network (rate-limit + cache), zod boundary validation, typed errors, call timeouts. Pure TS — no platform APIs. |
@comical/sdk |
BridgeBase class, cheerio-backed HTML helpers (fetchHtml, parse), URL resolver, re-exports contract types. What bridge authors import. |
@comical/testkit |
FixtureBackend (public-domain demo library), MockHost, network record/replay cassettes, the evaluateBridge coverage report + runConformance strict gate, plus live cover-size metrics (measureThumbnails) and transient/blocked-network tolerance (isTransientError). See packages/testkit/README.md. |
@comical/registry |
Registry index schema + zod validation, GitHub URL auto-resolution, SHA-256 integrity + Ed25519 signature verification, ManifestStore, RegistryManager (add/remove/browse/install/update/uninstall). |
@comical/library |
Optional local, cross-bridge reading library + tracking: collection, read/unread state, per-page resume, history, lists, new-chapter detection. Platform-agnostic Library service over a LibraryStore seam (in-memory store included). host-server mounts /library only when enabled. |
@comical/runtime |
Optional host-agnostic bridge-library coordination on top of library: addToLibrary, read-sync, favorites and tracker import, background new-chapter checks. Both host-server and host-rn build one when a library is enabled. |
@comical/downloads |
Optional durable offline downloads: portable manifest model, a DownloadsStore seam, the pure Downloads service, and DownloadEngine (bytes stored per platform through a BlobStore / PageFetcher). Mounted at /downloads only when enabled. |
@comical/sync |
Optional cross-device sync: per-device change logs, the merge every device agrees on, and the engine that drives them. Mounted at /sync only when enabled. |
@comical/host-native |
The shared JS harness bun run build:native bundles into the iOS (JSC) and Android (QuickJS) hosts: runs @comical/core with a native separate-context evaluator; the native layer injects _native_* callbacks. No platform APIs. |
| Package | Platform | Key capabilities |
|---|---|---|
@comical/host-bun |
Desktop / CLI | Bun fetch + cookie jar, FileStorage / MemoryStorage, node:vm loader |
@comical/host-server |
LAN / home server | Hono REST API, BridgeManager (load on demand, cache), SettingsStore, registry-aware |
host-ios (Swift sources) |
iOS / macOS | ComicalBridgeContext / ComicalTrackerContext: JSContext evaluator, URLSession, FileManager, Keychain. Consumed by source — see Native hosts. |
host-android (Kotlin sources) |
Android | ComicalBridgeContext / ComicalTrackerContext: QuickJS (~1 MB), OkHttp, file storage, EncryptedFile. Consumed by source — see Native hosts. |
@comical/host-rn |
React Native / Expo | Reusable in-process embedding layer: proxy BridgeProvider, reused-router transport, registry-download BundleSource, Hermes WebCrypto shim. See below. |
| Package | Description |
|---|---|
@comical/cli |
comical command-line host. Commands: list, serve, search, details, chapters, pages, test, record, registry ... |
The user-facing client is the separate comical-app repo (React Native + Expo: iOS and Android
natively, web via react-native-web, desktop via Electron). It is not part of this monorepo; it pins
this repo as a git submodule and either talks to host-server over HTTP or runs the same router
in-process (see Embedding the runtime in React Native).
| Directory | Description |
|---|---|
bridges/example-bridge/ |
Reference HTML bridge (chaptered "library"). Targets a user-supplied backend (URL via settings). Tested against the FixtureBackend public-domain library. |
bridges/direct-example/ |
Reference direct-read (chapterless "gallery") bridge. Tested against the DirectFixtureBackend. |
bridges/test-sprites/ |
Test bridge for sprite-thumbnail rendering — no network/auth; drives host-server's /test-sprite*.svg sheets (uniform + variable-size). |
A React Native / Expo app can run bridges on-device — no external server — by reusing this repo's
router in-process. @comical/host-rn is the reusable glue; the app supplies only three things it
owns.
- How it works. The app routes all data through a swappable transport. The embedded transport
drives
@comical/host-server'screateRouterin-process (router.fetch(new Request(...)), no socket) against anEmbeddedBridgeProviderwhose proxy bridges marshal each method call to a native JS engine (JSC on iOS, QuickJS on Android) via a native module implementing theNativeBridgeRuntimeJSON contract (initBridge/callBridge/disposeBridge). Bundles are downloaded, verified, and cached from a registry (RegistryBundleSource, reusing@comical/registry). - What the app provides: its native module (resolved and passed to
setNativeBridgeRuntime), its transport setter, and aSettingsStore. Then it callsconfigureEmbeddedRuntime+applyEmbeddedMode. Everything else lives in@comical/host-rn. - Node-free by construction.
host-rnand every comical package it imports use the Node-free subpaths (@comical/core/loader,@comical/host-server/router,@comical/host-server/bridge-provider,@comical/registry/{schema,fetcher,available}) — never the Node-bound barrels — so the whole graph type-checks and bundles under Hermes.comical-app(the cross-platform app) is the reference consumer; see itsapps/mobile/src/data/embedded/.
| Platform | Status | How it runs |
|---|---|---|
| Desktop (Windows/macOS/Linux) | ✅ M1 | comical serve or direct CLI — host-bun |
| Web browser | ✅ M2 | comical-app's web build, a REST client → host-server (the browser runs no bridge code) |
| iOS | ✅ M3 | comical-app running the router in-process via @comical/host-rn + ComicalBridgeContext (JSC); or any Swift app embedding host-ios directly |
| Android | ✅ M3 | comical-app running the router in-process via @comical/host-rn + ComicalBridgeContext (QuickJS); or any Kotlin app embedding host-android directly |
| Desktop app | ✅ | comical-app's Electron build runs createHost() on Electron's Node — node:vm bridges, no socket |
| Bridge registries | ✅ M4 | comical registry add/install/publish + RegistryManager in host-server |
Prerequisites: Bun ≥ 1.3.
git clone <repo>
cd comical
bun install # install workspace dependencies
bun run build # compile bridges → bridges/<id>/dist/bridge.js
bun test # run the full offline test suite (63 tests)The recommended way to use Comical — runs on any machine on your network, browser and app clients just call the REST API.
# Start a server and configure a bridge's backend yourself
bun run serve # server on :3100
# Then configure the example bridge's backend URL
curl -X PUT http://localhost:3100/bridges/example/settings \
-H "Content-Type: application/json" \
-d '{"baseUrl": "https://your-komga-instance.example.com"}'For a fixture-backed dev stack (no real backend needed) run
bun run devfrom the workspace root: it starts this repo'ssandbox/demo-server.tson :3100 andcomical-app's web dev server on :8081.
The host-server is published as a container image at ghcr.io/porksphere/comical-host.
# docker-compose.yml
services:
comical-host:
image: ghcr.io/porksphere/comical-host:latest
container_name: comical-host
environment:
- COMICAL_ORIGIN=*
# - COMICAL_TOKEN=change-me # optional bearer auth
volumes:
- ./comical-host:/data
ports:
- 3100:3100
restart: unless-stoppedThe published image bundles no bridges — you add sources (a registry, then bridges) at
runtime, via the REST API (POST /registries, POST /registries/:url/bridges/:id/install) or a
client's Settings. The mounted /data dir persists your library, settings, and installed bridges.
Tags: latest and sha-<commit> follow master; X.Y.Z / X.Y are cut from v* git tags.
To pair it with the web UI, see the full-stack compose in the
comical-app README. For a self-contained local
image that bundles this repo's example bridges, build the dev target:
docker build --target dev -t comical-host:dev .
Key REST endpoints:
GET /health
GET /bridges → list of bridges + update badges
GET /bridges/:id → bridge info + settings descriptors
PUT /bridges/:id/settings → configure backend URL / credentials
GET /bridges/:id/lists?q= → the bridge's list catalog
GET /bridges/:id/lists/:listId?page=&q=&filters=&sort=&dir= → entries within a list (q/filters/sort if searchable)
GET /bridges/:id/search?q=&page=&filters= → filters is URL-encoded JSON FilterValue[]
GET /bridges/:id/filters → Filter[] descriptors for the search UI
GET /bridges/:id/series/:seriesId
GET /bridges/:id/series/:seriesId/chapters
GET /bridges/:id/series/:seriesId/chapters/:chapterId/pages
GET /registries → user's added registries
POST /registries → add a registry
GET /registry/bridges → browse all available bridges
POST /registries/:url/bridges/:id/install → install a bridge
GET /registry/updates → available update versions
# List built bridges
bun run cli list
# One-off queries (without the server)
bun run cli search "alice" --bridge example --fixture
bun run cli details alice --bridge example --fixture
bun run cli chapters alice --bridge example --fixture
bun run cli pages alice alice-1 --bridge example --fixture
# Strict conformance gate (throws on the first failure)
bun run cli test --bridge example --fixture
# Coverage report — what works, what's missing (see "Evaluating a bridge")
bun run cli evaluate --bridge example --fixture
# Start the server
bun run cli serve --port 3100 --data-dir ./.comicalThe client lives in the separate comical-app repo. From the workspace root:
bun run dev # builds sibling bridges, starts the dev host-server (:3100) + the app's web build (:8081)Open http://localhost:8081. On the web the app is a thin REST client — the browser makes no direct
network requests to backends, has no bridge bundles, and runs no bridge code. On iOS/Android the same
app can instead run bridges on-device (see Embedding the runtime in React Native).
Install the SDK and create a bridge file:
// bridges/my-bridge/src/index.ts
import {
BridgeBase,
type BridgeInfo,
type InferSettings,
type PagedResults,
type SeriesEntry,
type SeriesList,
type SettingDescriptor,
defineBridge,
defineSettings,
} from "@comical/sdk";
// Strictly-typed settings: `InferSettings` derives the value shape from the descriptors.
const SETTINGS = defineSettings([
{ type: "string", key: "baseUrl", label: "Backend URL", required: true },
{ type: "string", key: "apiKey", label: "API key", secret: true }, // any string can be secret
{ type: "enum", key: "region", label: "Region",
options: [{ value: "us", label: "US" }, { value: "eu", label: "EU" }], default: "us" },
]);
type Settings = InferSettings<typeof SETTINGS>;
// → { baseUrl: string; apiKey?: string; region?: "us" | "eu" }
class MyBridge extends BridgeBase<Settings> {
readonly info: BridgeInfo = {
id: "my-bridge",
name: "My Bridge",
version: "0.1.0",
contractVersion: "2.1.0",
languages: ["en"],
nsfw: false,
capabilities: ["lists", "settings"],
};
getSettings(): SettingDescriptor[] {
return [...SETTINGS];
}
// this.settings.baseUrl is typed `string`; this.settings.region is `"us" | "eu" | undefined`.
private base() { return this.requireSetting("baseUrl"); }
// Browse — declare your own lists (capability "lists")
async getLists(): Promise<SeriesList[]> {
return [
{ id: "popular", name: "Popular", layout: "carousel", featured: true },
{ id: "new", name: "New", layout: "grid" },
];
}
async getListItems(listId: string, page: number): Promise<PagedResults<SeriesEntry>> {
const $ = await this.fetchHtml(`${this.base()}/${listId}?page=${page}`);
const items = $(".item").toArray().map(el => ({
id: $(el).attr("data-id") ?? "",
title: $(el).find(".title").text().trim(),
}));
return { items, page, hasNextPage: false };
}
// ... implement getSeriesDetails, getChapters, getChapterPages
// ... optionally add getSearchResults for the "search" capability
}
export default defineBridge(host => new MyBridge(host));Bridge rules:
- Never use
fetch,XMLHttpRequest, or any platform API directly. Onlythis.request()/this.fetchHtml()/this.fetchJson(). - Never hardcode a backend URL. Read it from
this.requireSetting("baseUrl"). - All image URLs in
Page.imageUrlmust be absolute. - Declare every capability you implement in
info.capabilities.
Build and test:
bun run build # compile to bridges/my-bridge/dist/bridge.js
bun run cli test --bridge my-bridge --set baseUrl=https://your-backend.example.comcomical evaluate runs your built bridge against a backend and prints a coverage report —
which declared capabilities actually work, plus behavioral probes and data-quality checks. It's
the developer-facing companion to comical test (which is a strict pass/fail gate).
bun run cli evaluate --bridge my-bridge --set baseUrl=https://your-backend.example.com
bun run cli evaluate --bridge my-bridge --fixture --json # machine-readable for CIEvaluating my-bridge …
core
✓ details round-trip the sampled id
⚠ series details have no genres
search
✓ search returned 22 item(s)
filters
✓ filter "genre" changed results (22→5)
sort
✓ sort "title" reorders results (asc ≠ desc)
Summary: 14 pass · 1 warn · 0 fail — PASS
Coverage: 5/5 declared capabilities exercised, 5 passing [lists, search, filters, sort, settings]
What it checks: capability↔method agreement, the details → chapters → pages round trip (id
stability, chapter ordering/uniqueness), and behavioral probes — filters narrow results,
sort reorders them, in-list search narrows a searchable list, and settings descriptors
are well-formed. Missing-but-optional data (covers, authors, genres) is a warn, not a fail.
- Coverage here means contract/capability coverage + data-quality heuristics — not code coverage, and not semantic correctness (it can't tell if a title is right, only that it's present).
- Severities: hard contract violations
fail(non-zero exit); quality/behavioral-no-effectwarn. Use--strictto fail the run on warnings too.--query Qseeds the search probe.
Bridges run against a live backend, so this is non-deterministic — best as a scheduled job (catches site drift) and/or a non-blocking PR check, rather than a hard gate:
# .github/workflows/bridges.yml
on:
pull_request:
schedule: [{ cron: "0 6 * * *" }] # daily — catch upstream site changes
jobs:
evaluate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install && bun run build
- run: bun run cli evaluate --bridge my-bridge --set baseUrl=${{ secrets.BACKEND_URL }} --jsonThe JSON report ({ bridgeId, results[], summary }) is stable to aggregate into a per-bridge
status table or badge across a repo of bridges.
A registry is a static index.json + bridge bundle files on any static host (GitHub Pages,
Cloudflare Pages, S3, self-hosted). The project operates no central registry.
Community registries hosted on GitHub advertise themselves with the
comical-registry repository topic — that page lists
every tagged registry, most-starred first. If you publish a registry in a GitHub repo, add the
comical-registry topic (gear icon next to "About" on the repo page) so others can find it.
Bridge source repos use the comical-bridge topic.
Topics are self-applied and unverified — they are discovery, not endorsement. Trust is established per-registry when you add it (see the integrity + trust model below).
# 1. Generate a signing keypair (keep the private key secret — never commit it)
bun run cli registry keygen --out registry.key.json
# 2. Build bridges and publish
bun run build
bun run cli registry publish \
--base-url https://my-github-user.github.io/my-bridges \
--out ./dist \
--key registry.key.json # optional; unsigned registries are also valid
# 3. Deploy ./dist/ to GitHub Pages, Cloudflare Pages, S3, etc.
# The index.json is at the root of the deployed URL.The published index.json shape:
{
"registryVersion": "1",
"updated": "2026-06-01T00:00:00Z",
"publicKey": "<base64url Ed25519 public key — omit for unsigned>",
"bridges": [
{
"id": "my-bridge",
"name": "My Bridge",
"version": "0.1.0",
"contractVersion": "2.0.0",
"languages": ["en"],
"nsfw": false,
"capabilities": ["search", "settings"],
"url": "https://my-github-user.github.io/my-bridges/bridges/my-bridge/0.1.0/bridge.js",
"sha256": "<lowercase hex SHA-256 of the bundle>",
"signature": "<base64url Ed25519 sig over the sha256 hex — omit for unsigned>"
}
]
}--nsfw true|false publishes only the bridges with that rating, and --display-name labels the
result. Run publish once per rating from the same build output and you get two independent
registries a user can add separately — someone who adds only the SFW one is never even offered the
others:
comical registry publish --bridges-dir ./.build --nsfw false \
--base-url https://me.github.io/my-bridges/sfw --out ./sfw --display-name SFW --key registry.key.json
comical registry publish --bridges-dir ./.build --nsfw true \
--base-url https://me.github.io/my-bridges/nsfw --out ./nsfw --display-name NSFW --key registry.key.jsonEach bundle belongs to exactly one rating (its own info.nsfw), so nothing is duplicated.
A registry's URL is the identity every install is bound to — settings, credentials, library entries and update authority all hang off it. Moving hosts, renaming a repo, or splitting one repo into two therefore needs the index to say so, or every installed bridge is stranded. Two fields do it, from opposite ends:
| Field | Asserted by | Use when |
|---|---|---|
movedTo |
the old host | the old URL can keep serving — clients are forwarded automatically |
movedFrom |
the new host | the old host is gone — a user who re-adds by hand adopts their installs |
Publish both. The new home claims its predecessor, and the old URL is replaced by a tombstone — an index with no bridges that exists only to forward:
# The new home, claiming succession from the URL it used to live at.
comical registry publish --bridges-dir ./.build \
--base-url https://me.github.io/new-home --out ./dist \
--moved-from https://me.github.io/old-home/index.json \
--key registry.key.json
# What stays behind at the old URL.
comical registry publish --tombstone \
--moved-to https://me.github.io/new-home/index.json --out ./old-home \
--key registry.key.jsonSign the tombstone with the same key as before. Key continuity is checked against the index
carrying movedTo — the tombstone itself — because that is the only cryptographic proof the same
operator still controls the old URL. With it, clients follow the move silently. Without it (unsigned,
or a new key), the move is held as pendingMove and every user has to confirm a "this registry
says it moved" prompt; --tombstone warns when you publish one unsigned. The same rule governs
movedFrom: a matching key adopts the predecessor's installs automatically, otherwise the client
offers it as pendingAdoption — anyone can claim to succeed anyone, so an unverified claim never
takes effect on its own.
Two more guards apply on top: a move is refused outright if none of the ids installed from the old
URL appear in the target index (that's a different registry wearing the name, not a move — a
partial overlap is fine, publishers drop bridges), and movedTo chains stop after
MAX_MOVE_HOPS (3) with cycles detected, so a broken or hostile index can't turn every fetch into a
crawl.
Via the CLI:
# GitHub repo URL is auto-resolved to raw.githubusercontent.com/…/main/index.json
bun run cli registry add github.com/someone/their-bridges
bun run cli registry browse github.com/someone/their-bridges
bun run cli registry install github.com/someone/their-bridges my-bridge
bun run cli registry updates # check for newer versions (never installs automatically)
bun run cli registry update my-bridgeVia the REST API (same operations, for the browser/app UI):
curl -X POST http://localhost:3100/registries \
-H "Content-Type: application/json" \
-d '{"url": "github.com/someone/their-bridges"}'
curl http://localhost:3100/registry/bridges
curl http://localhost:3100/registry/updatesIntegrity + trust model:
- SHA-256 checksum is always verified before a bundle is evaluated. Prevents corruption and transit tampering.
- Ed25519 signature verification is optional — present when the registry operator has generated a keypair. Trust is established when you add the registry (HTTPS identity + the registry operator's identity on GitHub).
- Updates are always manual.
checkUpdates()/GET /registry/updatesreports available versions; nothing installs without explicit user action. - Removing a registry orphans its installed bridges: they keep working from their cached bundles, on the server as on device, but can't update until the registry is added back.
- A registry that claims to have moved (
movedTo/movedFrom) is followed automatically only under key continuity; otherwise the claim is held for explicit user confirmation. See Moving a registry.
bun test # full offline test suite (63 tests across 10 files)
bun test ./packages/core/ # core unit + sandbox tests
bun test ./packages/registry/ # registry: URL resolution, checksums, signing, install cycle
bun test ./packages/host-bun/ # host-bun integration (real HTTP via fixture backend)
bun test ./packages/host-server/ # host-server REST API integration
bun test ./bridges/example-bridge/ # bridge conformance + snapshot testsiOS tests (requires Xcode on macOS):
cd packages/host-ios && swift testAndroid tests (requires Android SDK):
cd packages/host-android && ./gradlew test| Layer | What it covers | Network |
|---|---|---|
| Unit (core/sdk) | Loader, sandbox, rate-limiter, cache, version compat | None |
| Bridge conformance | All Bridge methods, id round-trips, invariants, against cassettes |
None (replay) |
| Snapshot | Parsed output pinned per bridge | None (replay) |
| Sandbox/security | require/process/fetch absent, resource guards, eval disabled |
None |
| Robustness/fuzz | Truncated HTML, 5xx, empty bodies, malformed JSON | None |
| Host-adapter integration | Real fetch + storage + cookie jar against local fixture server | Localhost only |
| Registry | Checksum, signature, install/orphan/update cycle, URL resolution | Localhost only |
| Live canary (CI, non-gating) | Real backends, cassette refresh | Live |
comical/
├── packages/
│ ├── contract/ @comical/contract — versioned interfaces + zod models
│ ├── core/ @comical/core — sandboxed loader, BundleEvaluator, gated network
│ ├── sdk/ @comical/sdk — BridgeBase, cheerio helpers
│ ├── testkit/ @comical/testkit — fixture backend, conformance suite, cassettes
│ ├── registry/ @comical/registry — index schema, URL resolution, signing, manager
│ ├── host-bun/ @comical/host-bun — desktop/CLI adapter
│ ├── host-server/ @comical/host-server — REST API server
│ └── cli/ @comical/cli — comical command-line tool
├── bridges/
│ └── example-bridge/ — reference bridge (public-domain demo backend)
├── packages/host-ios/ — Swift Package (JSC evaluator + URLSession)
├── packages/host-android/ — Kotlin library (QuickJS + OkHttp)
├── scripts/build.ts — bundles bridges to CJS
├── package.json — Bun workspace root
├── tsconfig.base.json — shared TS config
└── tsconfig.json — root typecheck (all packages)
bun install # install all workspace deps
bun run build # bundle bridges → bridges/<id>/dist/bridge.js
bun test # run all tests
bun run typecheck # tsc --noEmit across all packages
bun run serve # start comical-server on :3100
bun run cli -- --help # CLI helpSee docs/embedding.md — the minimal recipe per platform (Bun/Node with
loadBridge + createBunHost, a socket-free createHost() from host-server, Swift/Kotlin via the
native contexts, React Native via @comical/host-rn), which packages are Node-free, and what the
bridge layer promises a third party who wants bridges but not the library.
- Create
packages/host-<name>/. - Implement
HostCapabilitiesusing your platform's HTTP/storage/log APIs. - Implement
BundleEvaluatorusing your platform's JS engine (JSC, QuickJS, V8, etc.). - Pass your evaluator to
await loadBridge({ ..., evaluator: yourEvaluator }). - Add a test that loads
example-bridgeand callsrunConformance.
host-ios and host-android are consumed by source, not as packages. comical-app compiles
them straight out of its external/comical submodule — Gradle sourceSets on Android, a prebuild
plugin that copies the Swift files into its Expo module on iOS — together with the harness that
bun run build:native generates (host-ios/.../Resources/harness.js,
host-android/src/main/assets/comical_harness.js, both gitignored). The Package.swift and
build.gradle.kts in those directories exist so the native tests can run (swift test,
./gradlew :host-android:connectedDebugAndroidTest); they are not published artifacts and nothing
consumes them as such. The Expo module repeats host-android's runtime dependencies, so a version
bump there must be mirrored (the app's CI diffs the two lists).
A change to a native capability touches, in order: @comical/contract → host-native
(entry-jsc.ts / entry-quickjs.ts) → both native contexts → bun run build:native → a fresh
dev-client build of the app (the harness is baked in as an asset; a Metro reload will not pick it up).
The bridge contract (@comical/contract) is semver-versioned. A breaking change to the
Bridge interface or data models would normally bump CONTRACT_VERSION in version.ts, and the
runtime rejects bridges targeting an incompatible major version at load time. During local
development (pre-deployment) we change the contract in place without bumping — there are no
external bridges to migrate yet.
Additive changes (new optional methods, new SeriesList layouts, new BridgeCapability values)
are non-breaking.