Skip to content
porkspherePublic

About

No description or website provided.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Comical logo

Comical

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.


Table of contents


Vocabulary

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.

Design goals

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.

1. Cross-platform by construction

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.thumbnail is 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 SVG viewBox; Android uses BitmapRegionDecoder; iOS uses CGImage(cropping:). One contract shape, each platform slices the way it's good at — and the bytes are never recompressed.

2. A host should have to do as little as possible

Adopting Comical inside an application should be a small integration, not a port. A new host implements only:

  1. HostCapabilities — network.request, storage, log. That's the whole surface.
  2. 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.


Architecture

Layered model

┌──────────────────────────────────────────────────────────────────────┐
│  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.

The bridge contract

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

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.

Search filters and sort

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].

Favorites

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).

Host capability API

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.

Sandboxing

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.

HTTP and CORS

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 reference

Core packages

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.

Host adapters

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.

Applications

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).

Bridges

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).

Embedding the runtime in React Native

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's createRouter in-process (router.fetch(new Request(...)), no socket) against an EmbeddedBridgeProvider whose proxy bridges marshal each method call to a native JS engine (JSC on iOS, QuickJS on Android) via a native module implementing the NativeBridgeRuntime JSON 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 a SettingsStore. Then it calls configureEmbeddedRuntime + applyEmbeddedMode. Everything else lives in @comical/host-rn.
  • Node-free by construction. host-rn and 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 its apps/mobile/src/data/embedded/.

Platform support

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

Quick start

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)

Running the server

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 dev from the workspace root: it starts this repo's sandbox/demo-server.ts on :3100 and comical-app's web dev server on :8081.

Run with Docker

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-stopped

The 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

Using the CLI

# 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 ./.comical

Running the client

The 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).


Writing a bridge

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. Only this.request() / this.fetchHtml() / this.fetchJson().
  • Never hardcode a backend URL. Read it from this.requireSetting("baseUrl").
  • All image URLs in Page.imageUrl must 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.com

Evaluating a bridge

comical 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 CI
Evaluating 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-effect warn. Use --strict to fail the run on warnings too. --query Q seeds the search probe.

In CI

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 }} --json

The JSON report ({ bridgeId, results[], summary }) is stable to aggregate into a per-bridge status table or badge across a repo of bridges.


Registry

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.

Finding registries

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).

Publishing a registry

# 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>"
    }
  ]
}

Splitting one repo into several registries

--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.json

Each bundle belongs to exactly one rating (its own info.nsfw), so nothing is duplicated.

Moving a registry

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.json

Sign 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.

Adding a registry

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-bridge

Via 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/updates

Integrity + 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/updates reports 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.

Testing

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 tests

iOS tests (requires Xcode on macOS):

cd packages/host-ios && swift test

Android tests (requires Android SDK):

cd packages/host-android && ./gradlew test

Test pyramid

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

Development

Repo layout

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)

Key commands

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 help

Embedding the runtime elsewhere

See 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.

Adding a new host adapter

  1. Create packages/host-<name>/.
  2. Implement HostCapabilities using your platform's HTTP/storage/log APIs.
  3. Implement BundleEvaluator using your platform's JS engine (JSC, QuickJS, V8, etc.).
  4. Pass your evaluator to await loadBridge({ ..., evaluator: yourEvaluator }).
  5. Add a test that loads example-bridge and calls runConformance.

Native hosts

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).

Updating the contract

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.

About

No description or website provided.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages