Tamer wraps Lynx's native module / custom element APIs with autolinking (tamer.config.json + t4l link) so consumers don't manually edit Gradle / Podfile. This page shows how to author a Tamer-compatible native package: a JS-callable native module, a custom UI element, and an optional build-time plugin via tamer.config.ts.
For the underlying Lynx APIs, see the upstream guides:
After t4l add my-package + t4l link, the consumer's iOS Podfile picks up the podspec and the consumer's Android Gradle picks up :my-package. No manual wiring.
A native module exposes synchronous or callback-style methods to JS via NativeModules.MyModule. Tamer doesn't add anything new on top of Lynx — register your LynxModule class and the autolinker handles registration.
ios/mypackage/mypackage/Classes/MyModule.swift:
import Foundationimport Lynx@objcMemberspublic final class MyModule: NSObject, LynxModule { @objc public static var name: String { "MyModule" } @objc public static var methodLookup: [String: String] { [ "ping": NSStringFromSelector(#selector(ping(_:))), ] } @objc public override init() { super.init() } @objc public init(param: Any) { super.init() } @objc func ping(_ callback: @escaping (String) -> Void) { callback("pong") }}
JS surface (src/index.ts):
declare const NativeModules: { MyModule?: { ping(cb: (msg: string) => void): void }}export function ping(): Promise<string> { return new Promise((resolve) => { const mod = NativeModules?.MyModule if (!mod?.ping) return resolve('') mod.ping((msg) => resolve(msg)) })}
Custom elements register a JSX intrinsic backed by a native view (Android LynxUI<T>, iOS LynxUI subclass). Useful when CSS/Lynx-built-ins can't render what you need — icon glyphs, native maps, camera previews, charts.
Loosely modeled on tamer-icons/ios/.../TamerIconElement.m:
Some packages need to participate in the consumer's bundler — fetch fonts, generate routes, inject defines, copy assets. Ship an rsbuild plugin from your package and re-export it from tamer.config.ts. Tamer's plugin loader picks it up when the consumer imports your package.
Real example — packages/tamer-icons/src/plugin.ts downloads Material Icons + Font Awesome on first build, caches them, and copies them into the consumer's fonts/ directory:
import fs from 'fs'import path from 'path'import type { RsbuildPlugin } from '@rsbuild/core'import { MATERIAL_ICONS_URL, FONTAWESOME_SOLID_URL } from './fonts'async function fetchToBuffer(url: string): Promise<Buffer> { const res = await fetch(url) if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`) return Buffer.from(await res.arrayBuffer())}async function ensureFonts(pkgDir: string): Promise<void> { const fontsDir = path.join(pkgDir, 'fonts') fs.mkdirSync(fontsDir, { recursive: true }) const cacheDir = path.join(pkgDir, '.cache', 'tamer-icons') fs.mkdirSync(cacheDir, { recursive: true }) const materialPath = path.join(fontsDir, 'MaterialSymbolsOutlined.ttf') const materialCache = path.join(cacheDir, 'MaterialSymbolsOutlined.ttf') if (!fs.existsSync(materialCache)) { fs.writeFileSync(materialCache, await fetchToBuffer(MATERIAL_ICONS_URL)) } fs.copyFileSync(materialCache, materialPath) // ...repeat for other fonts}export function pluginTamerIcons(): RsbuildPlugin { return { name: 'tamer-icons', async setup(api) { const pkgDir = path.resolve(__dirname, '..') if (fs.existsSync(path.join(pkgDir, 'package.json'))) { await ensureFonts(pkgDir) } }, }}
Key design notes:
Cache before fetch. First build downloads, subsequent builds copy from cache. Offline builds keep working.
Run side effects in setup. rsbuild calls it once per build; safe place for IO.
No mutation of consumer source. Write into your own package directory or use api.transform / api.modifyRsbuildConfig instead of editing arbitrary files.