Wrapping pub.dev packages
The point of rendering with Flutter isn't just pixels — it's pub.dev: tens of thousands of maintained native plugins. Skal's codegen turns any Flutter widget into a JSX component and any plugin API into an awaitable JS call. No platform channels, no per-plugin JS glue.
Widgets — one line of config
List the package in your app's skal_codegen.yaml:
# flutter-host/lib/skal_codegen.yaml
packages:
- qr_flutter
- flutter_map
Then run the generator:
$ bun run codegen
The builder introspects each package's public widgets with Dart's analyzer — constructors, named constructors, parameter types — and emits adapters plus a manifest. On the JS side, typed components appear under skal-flutter:
import { QrImageView, FlutterMap, TileLayer } from 'skal-flutter'; <QrImageView data="https://skal.dev" size={220} />
Shimmer.fromColors becomes <ShimmerFromColors>. Prop types round-trip: enums, colors, durations, edge insets, gradients, text styles, value classes, even List<Marker>-style value children as JSX children.Headless capabilities — services
Most native capabilities have no widget: geolocation, biometrics, share, clipboard, permissions. Declare a service and the class's static methods become awaitable RPCs; a $-suffixed call subscribes a Dart Stream and returns an unsubscribe function:
# skal_codegen.yaml
services:
geo:
package: geolocator
class: Geolocator
import { createSkalService } from 'skal/runtime'; const geo = createSkalService('geo'); const pos = await geo.getCurrentPosition(); const stop = geo.getPositionStream$((p) => setPos(p));
geolocator wraps with zero hand-written Dart — its API is already static. A plugin with an instance-shaped API (local_auth) needs one tiny static forwarder class, and that forwarder is walked by codegen like any package:
// flutter-host/lib/adapters/auth_service.dart — the ENTIRE integration class AuthService { static final _auth = LocalAuthentication(); static Future<bool> authenticate(String reason) => _auth.authenticate(localizedReason: reason); }
And services aren't just plugin wrappers — they're the general "run this in Dart, call it from JS" hatch for your own logic. Crypto is the canonical case: native-speed hashing, heavy work on a background isolate, keys and file bytes that never enter JS (big inputs cross as paths, never payloads):
// flutter-host/lib/adapters/crypto_service.dart deps: crypto: ^3.0.0 class CryptoService { static String sha256Hex(String input) => sha256.convert(utf8.encode(input)).toString(); static Future<String> sha256File(String path) => Isolate.run( () async => (await sha256.bind(File(path).openRead()).last).toString()); }
Stateful widgets — hosts
Widgets whose constructor needs a live controller (camera preview, webview, video) use the host pattern: you write a ~15-line factory function; its parameters become JSX props, and the controller's public methods become imperative calls on a ref — one RPC op out, the reply back through the reply heap:
import { createSkalRef } from 'skal/runtime'; const cam = createSkalRef(); <Camera ref={cam} cameraName="back" /> await cam.takePicture(); cam.frames$((f) => ...); // Dart Streams subscribe with $
The skip report
After every run, anything codegen could not map is listed in lib/skal_codegen.json under "skipped" — with the reason and the exact remedy. Never diff generated output to find out what's missing; read the report. Many skips are rescued with zero Dart via overrides: in the same yaml — pin a generic widget's type arguments, supply a const: expression for one unmappable parameter, mark an indexed builder with builder: true, or hold a controller as an opaque JS-side handle with handle: true.
The escape-hatch ladder
When codegen can't map a library, escalate one rung at a time — every rung is a shipped pattern, and the line counts below are from real plugins:
overrides:in the yaml — one bad param, generic type args. 0 lines of Dart.- Static forwarder +
services:— instance-shaped plugin APIs. ~10–25 lines (local_auth: 24). - Platform-quirk shim — a platform needs an argument codegen can't encode (share_plus wants a
sharePositionOriginrect on iPad/iOS 26). ~8 lines. hosts:factory — live controller, async init. ~15–20 lines.- A plain local widget class — write normal Flutter in
lib/adapters/; codegen walks your code like any pub package. Same manifest, same'skal-flutter'import. - Raw registry —
SkalRegistry.registerWidget(name, (n, bridge) => …)for full manual control. Last resort; props read untyped off the node.
Permissions
Declare intent once in flutter-host/skal-permissions.json:
{ "camera": "Scan QR codes", "location": "Show places near you" }
bun run link translates it into every platform dialect — iOS + macOS Info.plist usage strings, macOS App Sandbox entitlements, AndroidManifest entries, and permission_handler's PERMISSION_* Podfile macros (without which every request answers permanentlyDenied). Idempotent, add-only; your hand-edited wording is never reverted.
When codegen isn't enough
- Non-void callbacks (
bool Function(T)) need a synchronous JS→Dart return, which would block the frame — permanently out of scope. Restructure around a prop or a ref method. - Builders keyed by a domain object (not an index) have nothing to key a subtree by — indexed builders work via
builder: true. - Payloads are for data, not blobs — strings beyond the heap sizes truncate. Pass paths and handles, not file contents.