react-native-web-bridge
v0.1.3
Published
Reflection-based bridge for calling JSON-clean web libraries from React Native via a WebView.
Maintainers
Readme
react-native-web-bridge
Call any browser-only JavaScript library from React Native — no native module, no per-library adapter, no hand-written glue code.
Point it at a library's bundled JS, and it discovers that library's method
surface at runtime through reflection, then exposes it on the RN side as a
typed, async, Proxy-backed object. The library runs in a hidden WebView
(a genuine browser engine); calling it from RN feels like calling a normal
async function.
const { bridge, ready, error, webView } = useLibraryBridge<ValidatorLib>({
html: buildValidatorBridgeHtml(),
});
if (ready) {
await bridge.isEmail("[email protected]"); // -> true
}Zero runtime dependencies. Works in Expo Go (no dev client, no config
plugin) and in a plain npx react-native init app — both confirmed live,
not just assumed.
Table of contents
- Why this exists
- Install
- Quick start
- What it's capable of
- How it works
- Comparison with alternatives
- What crosses the bridge
- Known caveats
- License
Why this exists
React Native has no DOM. Any web library that touches document/window/
HTMLElement directly — sanitizers, diagram renderers, barcode generators,
PDF engines, OCR — has nothing to render into on the RN side. The usual
answers are a native module (per-platform code, a real maintenance burden)
or a hand-written WebView wrapper (manually registering every method you
need, rewritten again for the next library).
This library automates the second path. It doesn't invent a new way to
cross the RN↔WebView boundary — postMessage RPC is a solved, commoditized
problem with half a dozen equivalent libraries. What's missing everywhere
else is automatic method discovery: reflection walks the target
library's prototype chain inside the WebView, builds a manifest, and a
Proxy on the RN side turns every discovered method into a callable async
function — without you writing a single hand-registered handler.
Install
npm install react-native-web-bridge
npx expo install react-native-webview # or: npm install react-native-webviewQuick start
Build your library's HTML bundle. The WebView needs a page that assigns your target library to
window.__RNWB_TARGET__before the bridge's injected script runs — typically a real npm package bundled with esbuild/webpack/Metro into an IIFE:// fixtures/validator.ts import { VALIDATOR_SCRIPT } from "./validatorBundle.generated"; // built by your bundler export function buildValidatorBridgeHtml() { return `<!DOCTYPE html><html><body><script>${VALIDATOR_SCRIPT}</script></body></html>`; } export interface ValidatorLib { isEmail(value: string): Promise<boolean>; }A library that's already a flat function bag (
validator,qrcode,DOMPurify) can be assigned towindow.__RNWB_TARGET__directly. A library whose entry point is a constructor (docx,pdf-lib,pptxgenjs) needs a small wrapper object instead — build an instance, chain calls, return a plain JSON-safe result. The bridge can call methods on an existing target; it can't call a constructor directly.Use
useLibraryBridge:import { useLibraryBridge } from "react-native-web-bridge"; import { buildValidatorBridgeHtml, type ValidatorLib } from "./fixtures/validator"; const { bridge, ready, error, webView } = useLibraryBridge<ValidatorLib>({ html: buildValidatorBridgeHtml(), }); // Render `webView` somewhere in the tree - it's a 0x0 hidden WebView // that does the actual bridging work, not a visible UI element. Pass // `style` in the options if the library's own rendered output (a map, // a chart) belongs on screen instead.ValidatorLibhere is hand-typed (see the type safety caveat below).
What it's capable of
Every example below is a real, unmodified npm package, proven working
through this exact mechanism — not a hypothetical. Each one gets its own
agent.*.test.ts run against the actual library, and a live demo page in
this repo's example app.
PDF & Office documents
Generate real .pdf, .docx, .xlsx, and .pptx files — and fill real
Word templates — none of which have a pure-JS RN-native equivalent today.
export interface PdflibLib {
createTextPdf(bodyText: string): Promise<{ base64: string; byteLength: number }>;
}
const { bridge } = useLibraryBridge<PdflibLib>({ html: buildPdflibBridgeHtml() });
const { base64 } = await bridge.createTextPdf("Hello from the bridge");
// base64 is a real, valid PDF - save it, share it, or hand it to another
// bridged library (see "Composing two libraries together" below).// Fill a real Word *template* with data - docxtemplater + docx composed
export interface DocxtemplaterLib {
createCertificate(name: string, course: string, date: string): Promise<{ base64: string }>;
}
const { base64 } = await bridge.createCertificate("Ada Lovelace", "Advanced Mathematics", "2026-07-30");Also covered: xlsx/SheetJS (spreadsheets, read back and rendered as a
real table), pptxgenjs (PowerPoint), pdfjs-dist (real PDF text
extraction, Mozilla's own PDF.js engine).
Cryptography & authentication
Real JWTs via Web Crypto, real WASM password hashing, real TOTP 2FA codes — none of which RN has a built-in primitive for.
export interface JoseLib {
signSimpleJwt(secretString: string, claims: Record<string, unknown>): Promise<string>;
verifyJwt(token: string, secretString: string): Promise<Record<string, unknown>>;
}
const jwt = await bridge.signSimpleJwt("a-real-secret", { sub: "user123", role: "admin" });export interface HashwasmLib {
hashPassword(password: string, saltSeed: string): Promise<string>; // Argon2id
verifyPassword(password: string, hash: string): Promise<boolean>;
}
const hash = await bridge.hashPassword("correct horse battery staple", "a-real-salt");Also covered: otpauth (RFC 6238 TOTP — the literal "set up 2FA" flow,
composed with qrcode below).
HTML processing
Sanitize untrusted HTML and convert HTML to Markdown — both genuinely impossible in RN natively, since both algorithms are defined in terms of parsing and rebuilding a real DOM tree, which RN has none of.
export interface DompurifyLib {
sanitize(dirty: string, config?: Record<string, unknown>): Promise<string>;
}
const clean = await bridge.sanitize('<img src=x onerror=alert(1)><b>Bold and safe</b>');
// -> "<b>Bold and safe</b>" - the script/handler attributes are gone, not just hiddenexport interface TurndownLib {
turndown(html: string): Promise<string>;
}
const markdown = await bridge.turndown("<h1>Release Notes</h1><p>Adds <b>real</b> Markdown export.</p>");Diagrams & barcodes — real native SVG
The standout pattern: the library's output comes back as a plain SVG
string, which renders as genuine native pixels via react-native-svg's
SvgXml — zero WebView pixels in what the user actually sees, despite the
WebView doing the generation.
import { SvgXml } from "react-native-svg";
export interface MermaidLib {
renderDiagram(definition: string): Promise<string>;
}
const svg = await bridge.renderDiagram(`
flowchart TD
A[Start] --> B{Is it working?}
B -->|Yes| C[Ship it]
B -->|No| D[Debug]
D --> B
`);
<SvgXml xml={svg} width={280} height={280} />export interface JsbarcodeLib {
renderBarcode(value: string, options?: Record<string, unknown>): Promise<string>;
}
const svg = await bridge.renderBarcode("012345678905", { format: "EAN13" });
<SvgXml xml={svg} width={260} height={140} />Also covered: qrcode (the original proof of this pattern — real QR
codes, plus a base64 PNG data-URL variant).
OCR — text recognition
Real WASM-based OCR (the same engine used across the browser ecosystem) — "scan a receipt/document" with no native module.
export interface TesseractLib {
recognizeText(imageDataUrl: string): Promise<{ text: string; confidence: number }>;
}
const { text, confidence } = await bridge.recognizeText(scannedImageDataUrl);Composing two libraries together
Because discovery is automatic for any JSON-clean-enough library, nothing stops bridging two unrelated libraries and using them together to solve a problem neither one solves alone — inside the same WebView, or across two.
// html2canvas + jsPDF: "HTML → PDF" with zero native modules, using
// capabilities only a WebView has - a real DOM to build the HTML in, and
// a real Canvas2D to rasterize it.
export interface HtmltopdfLib {
htmlToPdf(html: string): Promise<{ base64: string; byteLength: number }>;
}
const { base64 } = await bridge.htmlToPdf(`
<h1 style="color:#c00">Invoice #42</h1>
<p>Total due: <b>$120.00</b></p>
`);// otpauth + qrcode: build a real TOTP secret, then render its otpauth://
// URI as a real, scannable QR code - the literal "set up 2FA" screen.
const totp = await otpauthBridge.createTotp("Acme Inc", "[email protected]");
const qr = await qrcodeBridge.toString(totp.uri, { type: "svg" });
<SvgXml xml={qr} width={160} height={160} />Interactive maps — visible WebView
Every example above uses a hidden 0×0 WebView — the library's job is computing a result, not rendering pixels. A library whose actual output belongs on screen (a map, in this case) can opt into a visible WebView instead, driven live by ordinary RN buttons:
export interface LeafletMapLib {
flyToCity(lat: number, lng: number, zoom: number): Promise<{ lat: number; lng: number; zoom: number }>;
addMarkerAtCenter(label?: string): Promise<{ lat: number; lng: number }>;
getZoom(): Promise<number>;
}
const { bridge, webView } = useLibraryBridge<LeafletMapLib>({
html: buildLeafletBridgeHtml(),
style: { flex: 1 }, // <- visible, instead of the default hidden 0x0
});
<Button title="Fly to London" onPress={() => bridge.flyToCity(51.505, -0.09, 11)} />
{webView}How it works
- Discovery. A small injected script (
AGENT_SCRIPT) runs inside the WebView once the page loads, walks the target's prototype chain (Object.getOwnPropertyNames, own and inherited), and posts back a manifest of real method names. - Proxy. The RN side turns that manifest into a JS
Proxy— every discovered method becomes(...args) => Promise<result>, correlated by request id over a single orderedpostMessagechannel. - JSON-safety boundary. Every call argument is checked before it
crosses the bridge; anything that can't survive a
JSON.stringify/JSON.parseround trip (functions,undefined, circular refs, class instances) is rejected with a path-pointing error instead of silently corrupting on the wire. - Handles, callbacks, events. A method returning a class instance
becomes a live handle (a second
Proxyrouting further calls to that specific instance) instead of losing its methods. A function passed as a call argument is registered locally and revived into a real forwarding function on the WebView side — fires however many times the library actually calls it. Three event conventions (on/off, DOM'saddEventListener/removeEventListener, single-callbacksubscribe/unsubscribe) are auto-detected and wired the same way.
Comparison with alternatives
| | react-native-web-bridge | Hand-written WebView bridges¹ | Expo DOM Components ('use dom') | Comlink |
| --- | --- | --- | --- | --- |
| Automatic method discovery (no hand-registered glue per library) | Yes — reflection-based, at runtime | No — every method hand-registered | No — you hand-author the DOM-side component | No — you call Comlink.expose(obj) yourself |
| Problem domain | WebView ↔ RN | WebView ↔ RN | WebView ↔ RN (Expo-only) | Worker ↔ main thread |
| JSON-safety enforcement | Explicit, runtime-checked, path-pointing errors | Usually implicit (JSON.stringify does whatever it does) | Undocumented in detail | Implicit for plain values, explicit via transferHandlers for the rest |
| Arbitrary functions as call arguments | Yes — any function anywhere in an argument tree | No, or hand-wired for one specific case | No — only top-level async functions | Yes — transferHandlers generalize this |
| Remote object handles (stateful/chained APIs) | Yes, with a FinalizationRegistry GC safety net | No | No | Buildable on top, not built in |
| Runtime dependencies | 0 | Varies | N/A (part of Expo SDK) | 0 |
| Works in Expo Go (no dev client) | Yes, verified | Expected, same foundation | Yes (it's Expo's own feature) | N/A |
| Works in bare react-native init CLI | Yes, verified | Expected, same foundation | No — coupled to Expo's Metro/Router | N/A |
| Native rendering output | No (WebView's own pixels, unless the library's output is SVG/text) | No | No | N/A |
¹ e.g. libraries that solve postMessage RPC between RN and a WebView but
require registering every method by hand — a well-trodden, commoditized
pattern this library builds its transport layer on top of, then automates
the part that's usually manual.
The honest trade-off: automatic discovery means method names aren't checked against the library at compile time the way a hand-written, fully-typed bridge can be — you can't statically type-check against a surface that's only known via runtime reflection. That's the direct cost of not hand-writing glue for every library.
What crosses the bridge
- JSON-clean values — numbers, strings, plain objects/arrays — cross directly, both directions.
- Functions as call arguments are supported generally, not just for a
hardcoded
on/offpair — a function anywhere in a call's arguments gets registered locally and revived into a real forwarding function on the WebView side. Fires however many times the library actually calls it. - Event subscriptions auto-detect three conventions:
on/off, DOM'saddEventListener/removeEventListener, and a single-callbacksubscribe/unsubscribe(no event name). - Class-instance / chained-API results (
map.getCenter(), a fluent builder) come back as a live handle — aProxywhose methods route to that specific instance — instead of losing their methods. Callhandle.release()when done; there's also aFinalizationRegistrysafety net, but treat it as a backstop, not a substitute. - Binary results (a generated PDF/zip/image) need the target's own
method to base64-encode them before returning — a raw
Uint8Arrayis a class instance, not JSON-safe, same as any other one.
Known caveats
- The JSON-safety boundary is real, not just documented. Anything
that can't survive a
JSON.stringify/JSON.parseround trip (functions,undefined, class instances, circular refs) either gets rejected before crossing the bridge (call arguments) or degrades with awarningattached to the response (call results/event payloads). - Remote object handles need explicit
release(). TheFinalizationRegistry-based automatic cleanup is a safety net only — GC timing is non-deterministic. - Type safety is generated, not enforced. The generic type parameter
passed to
useLibraryBridge<T>isn't checked against the library at compile time (it can't be — the library's shape is only known via runtime reflection). A companioncodegenscript can generate method-name stubs from a real discovery run instead of a hand-typed guess, closing the typo/drift gap — it can't infer parameter/return types, only names. - Synchronous call semantics don't cross the bridge. Every bridged call is async, regardless of what the original library's own API looks like — a different JS engine/process (the WebView) is a hard boundary, not a transport limitation.
- No native rendering. Output is always the WebView's own pixels,
wired up as a live handle you can call methods on, not something that
reconciles into native
View/Textcomponents. Libraries that produce SVG/text output (barcode/QR/diagram generators, PDF text extraction) can still end up as genuine native pixels — render the returned markup withreact-native-svgor plain RN components — since in that case the WebView is only ever doing the computation, not the on-screen rendering. - A library needing live network access at runtime (OCR's trained-data download on first use, for example) isn't fully self-contained after bundling — worth knowing before relying on one offline.
License
MIT
