flutter_fjs

JS/TS runtime for Flutter. Embeds QuickJS-ng in native code, calls between JS and C++ directly (JSI-style, no method channel), runs source or precompiled QuickJS bytecode bundles, and renders HTML-like JS tags as real Flutter widgets.

This is the Flutter half of flutter-js. The JS half lives on npm as @ufjs/cli (build toolchain) and @ufjs/runtime (element API, Vue 3 custom renderer).

Prebuilt natives

The engine ships compiled, so consumer builds need no NDK, no CMake and no native compile step:

Platform Artifact
Android android/src/main/jniLibs/{armeabi-v7a,arm64-v8a,x86_64}/libfjs.so
iOS / macOS ios/fjs.xcframework, macos/fjs.xcframework (device, simulator, macOS)

Minimums: Android API 21, iOS 12.0, macOS 10.14.

Usage

Normally you do not write the host by hand — the CLI generates it, wired for both dev and release:

npx @ufjs/cli create my-app
cd my-app && npm run run:android

What follows is what that generated host does, for embedding the engine in an existing Flutter app.

dependencies:
  flutter_fjs: ^0.1.3

Running a bundle

The engine does not read assets itself: you hand it bytes. runSource takes JS text, runBundle takes a .fjsbundle (QuickJS bytecode, version-locked to the embedded engine).

import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_fjs/flutter_fjs.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final engine = FjsEngine();
  engine.onLog = (level, message) =>
      debugPrint('[js:${FjsLogLevel.of(level).name}] $message');

  final bundle = await rootBundle.load('assets/fjs/bundle.fjsbundle');
  engine.runBundle(bundle.buffer.asUint8List());

  runApp(MaterialApp(home: Scaffold(body: FjsView(engine: engine))));
}

FjsView vs FjsApp

Both render the JS UI tree; the difference is what owns navigation.

Use when
FjsView The app has one screen, or your own Flutter Navigator drives routing and you place a view per screen.
FjsApp The JS side uses fjs/router.

FjsApp is a Navigator whose page stack mirrors the JS router's, so router.push('/detail') becomes a real Flutter page push — platform transition, iOS back-swipe and Android system back all come with it, and popping tells JS to unmount the page. It renders each route with its own FjsView internally.

FjsApp(
  engine: engine,
  placeholder: const Center(child: CircularProgressIndicator()),
)

placeholder shows while a pushed route's chunk is still loading.

Split builds

fjs build --pages emits a shared prelude plus one chunk per route. Register the prelude once and give the engine a way to fetch chunks on demand:

final shared = await rootBundle.load('assets/fjs/shared.fjsbundle');
engine.addPrelude(shared.buffer.asUint8List());

engine.chunkLoader = (chunk) async {
  try {
    final data = await rootBundle.load('assets/fjs/pages/$chunk.fjsbundle');
    return data.buffer.asUint8List();
  } catch (_) {
    return null; // reported to JS as a mount with no page
  }
};

Dev server

fjs dev serves the bundle over HTTP with hot reload. Point the engine at it instead of loading assets:

await engine.connectDev('127.0.0.1', 38900);

10.0.2.2 from an Android emulator; the machine's LAN address from a physical device. fjs run passes the right one through a FJS_DEV dart-define.

Calling Dart from JS

engine.host.register('device', (args) => {
      'platform': Platform.operatingSystem,
      'locale': Platform.localeName,
    });

JS side: invokeHost('device', {}). See docs/jsi-and-native-modules.md.

Rebuilding the natives

The C++/QuickJS-ng sources are not part of the published package — nothing in a consumer build compiles them. They live in native/ in the repository, together with the scripts that regenerate the binaries above:

cd packages/flutter_fjs
tool/build-android.sh   # needs ANDROID_NDK_HOME
tool/build-apple.sh     # macOS + Xcode

License

MIT. Bundles QuickJS-ng (MIT) — see NOTICE and native/quickjs/LICENSE.

Libraries

flutter_fjs