Skip to content

[RFC] Headless JS Context — lightweight runtime for non-UI JavaScript libraries #874

@daleydeng

Description

@daleydeng

Summary

This RFC proposes adding a Headless JS Context to WebF — a lightweight JavaScript execution mode that provides the QuickJS runtime + Web APIs + module system without initializing the DOM/CSS rendering pipeline.

This would enable Flutter apps to leverage the massive npm ecosystem of pure-logic JS/TS libraries (authentication, real-time data, IoT protocols, cryptography, etc.) without the overhead of a full browser environment.

Problem

1. DOM overhead for non-UI libraries

Currently, every WebFController unconditionally initializes:

  • Document + Window + all HTML element constructors
  • CSSOM infrastructure (style resolution, layout algorithms)
  • Rendering command buffer (pendingUICommands)

This is unnecessary when running libraries like supabase-js or mqtt.js that never touch the DOM. Each context carries the full memory footprint of a browser page even when only fetch + WebSocket + localStorage are needed.

2. No official lightweight alternative

Mercury (mercuryjs) was the previous answer for headless JS execution, but it has been deprecated. There is currently no official way to run JS libraries in a Flutter app via WebF without DOM overhead.

3. preRendering mode is a workaround, not a solution

WebFController with preRendering mode can execute JS without a mounted widget, but:

  • Still initializes the full DOM tree (initBridge()allocateNewPage() → Document/Window/Elements)
  • clientWidth/clientHeight/getBoundingClientRect return 0 — some libraries' feature detection breaks
  • evaluateJavaScripts() returns Future<void>no way to get return values from JS
  • No JS→Dart event push channel (only Dart→JS via dispatchEvent)

Use Cases

Pure-logic JS/TS libraries that would benefit from headless execution:

Library Purpose Required Web APIs
@supabase/supabase-js Auth, Database, Realtime, Storage fetch, WebSocket, localStorage
firebase/auth + firebase/firestore Authentication + Database fetch, XMLHttpRequest, localStorage
@aws-sdk/* AWS services SDK fetch
mqtt.js IoT MQTT protocol WebSocket
socket.io-client Real-time communication WebSocket, XMLHttpRequest
graphql-request / urql (core) GraphQL clients fetch
zod / ajv Data validation None (pure computation)
crypto-js / tweetnacl / jose Cryptography, JWT None (or crypto.subtle)
protobufjs Protocol Buffers encoding None
@tanstack/query-core Async state management None
rxjs Reactive programming None
i18next Internationalization None
date-fns / dayjs Date manipulation None

These libraries represent a massive portion of the npm ecosystem — all pure logic, no DOM needed.

Proposal

Core: Headless JS Context

A new mode for WebFController (or a new lightweight controller class) that:

  1. Skips DOM initialization — no Document, Window, Element constructors, no CSSOM
  2. Retains Web API polyfills — fetch, WebSocket, localStorage, sessionStorage, timers (setTimeout/setInterval/requestAnimationFrame), URL/URLSearchParams, TextEncoder/TextDecoder, AbortController, EventSource, crypto.randomUUID()
  3. Retains the module systemdefineModule / invokeModuleAsync / addWebfModuleListener work identically to full contexts, enabling headless contexts to call the same Dart modules as UI contexts
  4. Runs on a separate DedicatedThread — isolated from UI contexts to prevent blocking

Enhancement 1: evaluateJavaScripts with return values

Current:

Future<void> evaluateJavaScripts(String code);

Proposed:

Future<dynamic> evaluateJavaScripts(String code); // returns the JS expression result

This is critical for Dart→JS function calls. Without return values, every interaction must be routed through the module callback system, which is awkward for simple queries like:

// What we want:
final user = await headlessContext.evaluateJavaScripts(
  'supabase.auth.getUser()'
);

// What we have to do today:
// 1. Define a module just to receive the result
// 2. Call evaluateJavaScripts to trigger the query
// 3. Have JS call invokeModuleAsync to push the result back
// 4. Collect it in Dart via a Completer

Enhancement 2: JS→Dart event channel

Current architecture supports:

  • ✅ JS → Dart calls (invokeModuleAsync)
  • ✅ Dart → JS events (dispatchEvent)
  • ❌ JS → Dart events (missing)

For real-time libraries (Supabase Realtime, MQTT, Socket.IO), JS receives streaming data via WebSocket callbacks. There is no way to push these events to Dart without polling via module calls.

Proposed: A postMessage-style API where JS can emit events that Dart listens to:

// JS side
webf.postMessage('channel', { type: 'new_message', data: {...} });
// Dart side
headlessContext.onMessage('channel', (data) {
  // Update Flutter UI with real-time data
});

Suggested Dart API

// Create a headless context (no DOM, no rendering)
final context = await WebFController.createHeadless(
  bundle: WebFBundle.fromUrl('assets:///supabase_bundle.js'),
  thread: DedicatedThread(),  // isolated thread
  onError: (String error) => talker.error('[Supabase] $error'),
);

// Call JS functions and get return values
final result = await context.evaluate('supabase.auth.getSession()');

// Listen to JS-pushed events
context.onMessage('realtime', (data) {
  setState(() => messages.add(data));
});

// Existing module system still works
// JS can call: webf.invokeModuleAsync('SecureStorage', 'get', 'token')

// Lifecycle management
context.dispose();

Technical Considerations

Bundle Loading

Headless contexts should accept WebFBundle (same as today). A Vite-built IIFE bundle wrapping supabase-js works without HTML. The WebFBundle.fromUrl() and .fromContent() APIs are sufficient.

Node.js API Polyfills

Many npm browser builds still reference:

  • process.env.NODE_ENV — should be handled by the build tool (define in Vite)
  • crypto.randomUUID() — would be valuable to support in WebF's crypto binding
  • Buffer — can be polyfilled via npm buffer package in the bundle

Memory & Isolation

Each headless context should have its own QuickJS heap (same as today's context isolation). No shared globals between contexts. Data sharing goes through Dart-side module calls or shared storage (SQLite, localStorage).

Error Handling

Headless contexts must wire onJSError — without a visible UI, silent error disposal (current default when onJSError is null) is dangerous. Consider making onError a required parameter for headless contexts.

Alternatives Considered

Approach Pros Cons
Mercury (deprecated) Lightweight, Apache-2.0 Deprecated, no module system integration
preRendering mode Works today Full DOM overhead, no return values, no JS→Dart events
flutter_js / jsengine Third-party QuickJS bindings No WebF module system, no fetch/WebSocket polyfills, separate ecosystem
InAppWebView evaluateJS Full browser APIs Heavy WebView overhead, platform-specific, no module system

Impact

This feature would position WebF as not just a web rendering engine, but a comprehensive JS runtime platform for Flutter — enabling developers to tap into the entire npm ecosystem for both UI and non-UI use cases. The combination of headless contexts (for logic) + full contexts (for UI) + shared module system (for native capabilities) would be uniquely powerful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions