npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

aibridgejs

v0.5.0

Published

Transport-agnostic bridge core for iframe, Flutter InAppWebView, and mock runtimes. JSON-safe request/response with AbortSignal cancellation and adapter-isolated host coupling.

Downloads

1,439

Readme

aibridgejs

npm version CI License AI Generated 繁體中文

Transport-agnostic bridge core for iframe, Flutter InAppWebView, and mock runtimes. JSON-safe request/response protocol with AbortSignal cancellation and adapter-isolated host coupling.

Part of the ai*js micro-runtime ecosystem — see also aifsmjs (FSM) and aiecsjs (ECS).


Overview

Different host runtimes speak different bridging dialects. An iframe listens on postMessage. A Flutter InAppWebView exposes named JS handlers and fires a platform-ready event. A local dev environment needs neither. aibridgejs provides a single stable API on top of all of them.

The core package is pure TypeScript. It knows nothing about window, flutter_inappwebview, or any other host global. All host-specific logic lives in an adapter that you supply at construction time. The adapters ship as subpath exports of the same package, so you only load what you need.

Request/response correlation works through a string id embedded in every envelope. Concurrent call() invocations each get a unique id; responses are matched back to their pending entry by that id. Readiness gating ensures that no call is attempted before the adapter has signalled the host is ready to receive — which matters on Android InAppWebView where the JS handler registry is available before the underlying Flutter bridge is stable.


Installation

pnpm add aibridgejs
# or
npm install aibridgejs

The package ships five subpath exports:

| Subpath | Contents | |---|---| | aibridgejs | Core — createBridge, types, error classes | | aibridgejs/mock | createMockAdapter — loopback for dev and unit tests · dev only | | aibridgejs/iframe | createIframeAdapter — origin-validated postMessage transport · pure-web safe | | aibridgejs/flutter | createFlutterAdapter — InAppWebView JS handler transport · requires native shell | | aibridgejs/detect | detectBridgeAdapter — auto-pick an adapter from host globals · pure-web safe (auto-fallback) |

See STABILITY.md for the full stability contract and pure-web safety semantics.


Quick Start

The mock adapter loops outbound messages back to the subscriber. It needs no host environment, which makes it ideal for local development and unit tests.

import { createBridge } from 'aibridgejs';
import { createMockAdapter } from 'aibridgejs/mock';

const adapter = createMockAdapter();
const bridge = createBridge({ adapter, timeoutMs: 5000 });

// Wait for the adapter to become ready.
await bridge.ready();

// Make a typed RPC call.
const result = await bridge.call('session.getToken', { scope: 'game' });
console.log(result);

// Listen for host-pushed events.
const off = bridge.on('game.stateChanged', (payload) => {
  console.log('state:', payload);
});

// Emit a notification toward the host (no response expected).
await bridge.emit('player.ready', { playerId: 'p1' });

// Teardown.
off();
bridge.dispose();

Protocol

Every message crossing the bridge is a BridgeEnvelope — a discriminated union with three variants.

export type BridgeEnvelope =
  | {
      kind: 'request';
      id: string;        // UUID generated by the caller; echoed in the paired response
      method: string;    // dot-namespaced RPC method name
      payload?: unknown; // JSON-serialisable arguments
      timestamp: number; // Date.now() at send time; useful for latency debugging
    }
  | {
      kind: 'response';
      id: string;        // matches the paired request id
      ok: boolean;       // true = success; false = application-level error
      payload?: unknown; // return value when ok is true
      error?: {
        code: string;
        message: string;
        detail?: unknown;
      };
      timestamp: number;
    }
  | {
      kind: 'event';
      event: string;     // dot-namespaced event name
      payload?: unknown;
      timestamp: number;
    };

The kind field is the discriminant. Adapters must produce and consume BridgeEnvelope values only; they must not add or remove fields. All payloads must be JSON-serialisable — no undefined, Date, Map, Set, or class instances.


Core API

createBridge(options)

import { createBridge } from 'aibridgejs';

const bridge = createBridge({
  adapter: BridgeAdapter,   // required
  timeoutMs?: number,       // per-call timeout in ms; default 10000
});

Returns a Bridge object with the following methods.


bridge.ready(options?)

ready(options?: { signal?: AbortSignal }): Promise<void>

Resolves when the adapter reports the host is ready to receive messages. All subsequent call() and emit() invocations wait on readiness internally, so explicit await bridge.ready() is optional but recommended at startup to surface connection errors early.

Rejects with the AbortSignal reason if cancelled before the adapter becomes ready.


bridge.call<T>(method, payload?, options?)

call<T = unknown>(
  method: string,
  payload?: unknown,
  options?: { timeoutMs?: number; signal?: AbortSignal },
): Promise<T>

The generic T is the expected shape of the response payload. The runtime does NOT validate the response — T is a caller assertion. Validate with Zod / Valibot at the boundary if the host is untrusted. Defaults to unknown so existing callers without a generic remain type-safe.

Sends a request envelope and waits for the matched response. Rejects if:

  • The response ok is false (application-level error from the host).
  • The timeout elapses (default: options.timeoutMs ?? constructor timeoutMs).
  • The AbortSignal fires before a response arrives.
  • dispose() is called while the call is in flight.

Pending entries are removed from the internal map in all rejection paths to prevent leaks.


bridge.emit(event, payload?)

emit(event: string, payload?: unknown): Promise<void>

Sends an event envelope toward the host. Fire-and-forget — no response is expected and no pending entry is tracked.


bridge.on(event, listener, options?)

on(
  event: string,
  listener: (payload: unknown) => void,
  options?: { signal?: AbortSignal; once?: boolean },
): () => void

Registers a listener for inbound event envelopes whose event field matches the given name. Returns an unsubscribe function. Passing once: true auto-removes the listener after the first invocation. Passing a signal removes the listener when the signal aborts.


bridge.platform()

platform(): 'iframe' | 'flutter' | 'mock' | 'unknown'

Returns the platform string reported by the active adapter. Useful for conditional behaviour without coupling to a specific adapter import.


bridge.reset()

reset(): void

Resets internal state — clears pending calls, event listeners, and readiness state — without disposing the adapter. Use when the host reloads the web page inside the same native container and you need to reinitialise without constructing a new bridge.


bridge.dispose()

dispose(): void

Rejects all pending call() promises, removes all event listeners, and calls adapter.dispose(). After dispose(), the bridge must not be used.


Error semantics

Every error class extends BridgeError. Treat them as a discriminated union when deciding whether to retry:

| Error | When it fires | Retryable? | Recommended response | |---|---|---|---| | BridgeResetError | A reset() happened while the call was in flight (or in the ready-wait that precedes it). | Yes. | Re-issue the call. The caller's AbortSignal is unaffected; a simple for loop with a max-retry cap is enough. | | BridgeTimeoutError | No response within timeoutMs (default 10 s, overridable per call). | Yes. | Retry with exponential backoff. If the host is genuinely down, the retry will surface as another timeout or a transport error — both are still observable. | | BridgeRemoteError | The host responded with ok: false. Holds the host's code, message, and optional detail. | Depends on the remote contract. | Switch on error.code and follow the host's documented policy. Generic retry will often re-trigger the same application error. | | BridgeDisposedError | dispose() was called before or during the call. | No. | The bridge is gone. Construct a new createBridge(...) if you still need the channel. | | BridgeError | Catch-all base. Used if you throw from your own adapter or want to catch all of the above. | — | Inspect error.name to discriminate. |

The runtime does NOT auto-retry. Wrap the call in your own retry helper when the policy is "transient errors are recoverable":

// Reference implementation. In production, also accept an external
// AbortSignal and surface a circuit-breaker once the host is gone for good.
async function callWithReset<T>(
  bridge: Bridge,
  method: string,
  payload?: unknown,
  maxAttempts = 3,
): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await bridge.call<T>(method, payload);
    } catch (err) {
      const isRetryable = err instanceof BridgeResetError || err instanceof BridgeTimeoutError;
      if (!isRetryable || attempt + 1 >= maxAttempts) throw err;
      // Exponential backoff with jitter — keeps the host from being hammered
      // by a hot reload storm and gives a reset / re-ready cycle time to settle.
      const delayMs = Math.min(1000, 100 * 2 ** attempt) + Math.floor(Math.random() * 50);
      await new Promise<void>((r) => setTimeout(r, delayMs));
    }
  }
}

Adapters

Mock adapter

Pure-web safety: dev only — loopback; not for production traffic.

import { createMockAdapter } from 'aibridgejs/mock';

const adapter = createMockAdapter();

The mock adapter is immediately ready (no async wait). Outbound messages posted via the adapter's post() method are echoed synchronously to every active subscriber, including the bridge itself. This closes the loop for unit tests: the test can subscribe to the adapter before creating the bridge, inspect what the bridge sends, and push synthetic responses back.

const adapter = createMockAdapter();
const messages: BridgeEnvelope[] = [];

adapter.subscribe((envelope) => {
  messages.push(envelope);
});

const bridge = createBridge({ adapter });
await bridge.emit('player.ready', { id: 'p1' });

console.log(messages[0].kind); // 'event'

iframe adapter

Pure-web safety: pure-web safe — pure postMessage API; works in any browser tab.

import { createIframeAdapter } from 'aibridgejs/iframe';

const adapter = createIframeAdapter(window, {
  targetOrigin: 'https://shell.example.com',
});

The iframe adapter uses postMessage for outbound messages and window.addEventListener('message', ...) for inbound. Every inbound message is validated:

  1. event.origin must exactly match targetOrigin.
  2. event.source must match the expected frame reference, unless source-checking is explicitly disabled by passing expectedSource: null.
  3. The parsed body must be a valid BridgeEnvelope (has kind); malformed messages are silently discarded.

targetOrigin: '*' is explicitly forbidden and throws at construction time.

Auto-detection. detectBridgeAdapter inspects the host globals and returns the most specific adapter available. It lives in its own subpath so the core stays small.

Pure-web safety: pure-web safe (auto-fallback) — falls back to mock when no shell is detected.

import { createBridge } from 'aibridgejs';
import { detectBridgeAdapter } from 'aibridgejs/detect';

const adapter = detectBridgeAdapter(window, {
  iframe: { targetOrigin: 'https://shell.example.com' },
});
const bridge = createBridge({ adapter });

Detection order: Flutter handler present → createFlutterAdapter; window.parent !== windowcreateIframeAdapter; fallback → createMockAdapter.


Flutter adapter

Pure-web safety: requires native shell — needs window.flutter_inappwebview.callHandler; ready() will hang in a plain browser tab.

import { createFlutterAdapter } from 'aibridgejs/flutter';

const adapter = createFlutterAdapter(window, {
  handlerName: 'aibridgejs',
  waitForReadyEvent: true,
  readyEventName: 'flutterInAppWebViewPlatformReady',
});

The Flutter adapter maps post() to window.flutter_inappwebview.callHandler(handlerName, envelope) and receives inbound messages via a JS handler registered from the Dart side.

waitForReadyEvent: true defers ready() resolution until the named DOM event fires. This is necessary on some Android configurations where flutter_inappwebview is present in the global scope before the underlying message channel is fully established. On iOS and newer Android builds the event fires quickly; the option adds negligible latency and prevents a class of race-condition bugs.

All payloads exchanged with the handler must be JSON-serialisable. The InAppWebView callHandler bridge performs its own JSON round-trip; class instances, Date, and non-enumerable properties are silently dropped.


Security

iframe

| Requirement | Consequence of violation | |---|---| | targetOrigin must be an exact HTTPS origin | Wildcard * throws at adapter construction | | Every inbound event.origin is validated | Wrong-origin messages are discarded without error | | event.source is validated against the expected frame (unless disabled with expectedSource: null) | Messages from unexpected frames are discarded when source-checking is enabled |

Never relax targetOrigin to * in production. A cross-origin page that can postMessage to your frame can forge any method call or event if origin validation is absent.

Flutter InAppWebView

Recommended Dart-side hardening:

InAppWebView(
  initialSettings: InAppWebViewSettings(
    allowingReadAccessTo: WebUri('https://your-game.example.com'),
    javaScriptEnabled: true,
  ),
  onWebViewCreated: (controller) {
    controller.addJavaScriptHandler(
      handlerName: 'aibridgejs',
      callback: (args) async {
        // args[0] is the BridgeEnvelope JSON object.
        return handleBridgeMessage(args[0]);
      },
    );
  },
);

Additional hardening steps:

  • Set allowedOriginRules on the native WebMessageListener to restrict which origins can send messages.
  • Restrict handlers to the main frame when the web content does not use sub-frames.
  • Use InAppWebView user scripts for bridge initialisation rather than evaluateJavascript after page load; user scripts execute before page JS runs and eliminate a setup race.
  • Never log BridgeEnvelope payloads that may carry session tokens or user identifiers.

General

The core bridge discards any inbound message that does not parse as a valid BridgeEnvelope. It logs nothing itself; if you need audit logging, wrap adapter.subscribe in your own interceptor.


Flutter Hybrid App Integration

This section shows a complete wiring pattern for a Flutter app that embeds a web game page and communicates through aibridgejs.

Flutter App
  └── InAppWebView
        └── Web page (Vite / Svelte 5)
              └── aibridgejs (Flutter adapter)

Dart side

import 'dart:convert';

import 'package:flutter_inappwebview/flutter_inappwebview.dart';

class GameWebView extends StatefulWidget {
  const GameWebView({super.key});

  @override
  State<GameWebView> createState() => _GameWebViewState();
}

class _GameWebViewState extends State<GameWebView> {
  InAppWebViewController? _controller;

  @override
  Widget build(BuildContext context) {
    return InAppWebView(
      initialUrlRequest: URLRequest(
        url: WebUri('https://game.example.com/index.html'),
      ),
      initialSettings: InAppWebViewSettings(
        javaScriptEnabled: true,
      ),
      onWebViewCreated: (controller) {
        _controller = controller;

        // Register the handler that receives BridgeEnvelope requests from JS.
        controller.addJavaScriptHandler(
          handlerName: 'aibridgejs',
          callback: (args) async {
            final envelope = args[0] as Map<String, dynamic>;
            final kind = envelope['kind'] as String?;

            if (kind == 'request') {
              final method = envelope['method'] as String;
              final id = envelope['id'] as String;

              // Route to your Flutter business logic.
              final result = await _handleMethod(method, envelope['payload']);

              // Return a response envelope; InAppWebView serialises the
              // return value back to the JS callHandler Promise.
              return {
                'kind': 'response',
                'id': id,
                'ok': true,
                'payload': result,
                'timestamp': DateTime.now().millisecondsSinceEpoch,
              };
            }

            if (kind == 'event') {
              _handleEvent(
                envelope['event'] as String,
                envelope['payload'],
              );
            }

            return null;
          },
        );
      },
    );
  }

  // Push an event from Flutter into the web page.
  Future<void> pushEvent(String event, Object payload) async {
    final envelope = {
      'kind': 'event',
      'event': event,
      'payload': payload,
      'timestamp': DateTime.now().millisecondsSinceEpoch,
    };
    await _controller?.evaluateJavascript(
      source: 'window.__aibridgejs_push(${jsonEncode(envelope)})',
    );
  }

  Future<Object?> _handleMethod(String method, Object? payload) async {
    switch (method) {
      case 'session.getToken':
        return {'token': await SessionService.instance.getToken()};
      default:
        throw UnimplementedError('Unknown method: $method');
    }
  }

  void _handleEvent(String event, Object? payload) {
    // Handle events emitted from the web page (fire-and-forget).
  }
}

JavaScript side

// bridge.ts — run once at game startup
import { createBridge } from 'aibridgejs';
import { createFlutterAdapter } from 'aibridgejs/flutter';

const adapter = createFlutterAdapter(window, {
  handlerName: 'aibridgejs',
  waitForReadyEvent: true,
  readyEventName: 'flutterInAppWebViewPlatformReady',
});

export const bridge = createBridge({ adapter, timeoutMs: 8000 });

// Expose a push target for evaluateJavascript from the Flutter side.
(window as unknown as Record<string, unknown>).__aibridgejs_push =
  adapter.receive.bind(adapter);
// game-init.ts
import { bridge } from './bridge';

await bridge.ready();

const { token } = await bridge.call<{ token: string }>('session.getToken', { scope: 'game' });

bridge.on('game.forceEnd', (payload) => {
  handleForceEnd(payload);
});

Readiness and timeout handling

const controller = new AbortController();

setTimeout(() => controller.abort(new Error('bridge timeout')), 10_000);

try {
  await bridge.ready({ signal: controller.signal });
} catch (err) {
  // Show a connection error screen; do not proceed into the game.
  showFatalError('Failed to connect to host runtime.');
  return;
}

Testing

Vitest configuration

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    clearMocks: true,
    restoreMocks: true,
    fakeTimers: { toFake: ['setTimeout', 'clearTimeout', 'Date'] },
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
    },
  },
});

TDD gate cases

These cases define the minimum correctness bar for any aibridgejs implementation.

| Case | Description | |---|---| | Ready gating | call() and emit() must queue until ready() resolves | | ID correlation | Concurrent call() invocations resolve independently by their response id | | Timeout rejection | Pending call rejects after the configured timeoutMs and removes its pending entry | | Abort rejection | Aborting a signal mid-flight rejects the call and removes its pending entry | | Malformed discard | An inbound message that is not a valid BridgeEnvelope is silently dropped | | Origin rejection | An iframe message from a wrong origin or source is silently dropped | | Dispose rejection | dispose() rejects all in-flight calls with a BridgeDisposedError |

Representative test suite

import { describe, expect, test, vi } from 'vitest';
import { createBridge } from 'aibridgejs';
import { createMockAdapter } from 'aibridgejs/mock';

describe('aibridgejs', () => {
  test('resolves ready once adapter becomes available', async () => {
    const adapter = createMockAdapter();
    const bridge = createBridge({ adapter });
    await expect(bridge.ready()).resolves.toBeUndefined();
  });

  test('correlates concurrent responses by request id', async () => {
    const adapter = createMockAdapter();
    const bridge = createBridge({ adapter });

    // Intercept outbound requests and reply with synthetic responses.
    adapter.subscribe((envelope) => {
      if (envelope.kind !== 'request') return;
      adapter.receive({
        kind: 'response',
        id: envelope.id,
        ok: true,
        payload: { echo: envelope.method },
        timestamp: Date.now(),
      });
    });

    await bridge.ready();

    const [a, b] = await Promise.all([
      bridge.call('method.a'),
      bridge.call('method.b'),
    ]);

    expect((a as { echo: string }).echo).toBe('method.a');
    expect((b as { echo: string }).echo).toBe('method.b');
  });

  test('rejects pending call after timeout and removes pending entry', async () => {
    vi.useFakeTimers();

    const adapter = createMockAdapter(); // never replies
    const bridge = createBridge({ adapter, timeoutMs: 1000 });

    await bridge.ready();

    const pending = bridge.call('silent.method');
    vi.advanceTimersByTime(1001);

    await expect(pending).rejects.toThrow(/timeout/i);

    vi.useRealTimers();
  });

  test('rejects call when AbortSignal fires and removes pending entry', async () => {
    const adapter = createMockAdapter();
    const bridge = createBridge({ adapter });
    const controller = new AbortController();

    await bridge.ready();

    const pending = bridge.call('silent.method', undefined, {
      signal: controller.signal,
    });
    controller.abort(new Error('user cancelled'));

    await expect(pending).rejects.toThrow('user cancelled');
  });

  test('ignores iframe message from wrong origin', async () => {
    const { createIframeAdapter } = await import('aibridgejs/iframe');
    const received: unknown[] = [];

    const adapter = createIframeAdapter(new EventTarget() as never, {
      targetOrigin: 'https://shell.example.com',
    });

    adapter.subscribe((envelope) => received.push(envelope));

    adapter.dispatchTestMessage(
      { kind: 'event', event: 'test', timestamp: Date.now() },
      { origin: 'https://evil.example.com' },
    );

    expect(received).toHaveLength(0);
  });

  test('dispose rejects all pending calls', async () => {
    const adapter = createMockAdapter();
    const bridge = createBridge({ adapter });

    await bridge.ready();

    const pending = bridge.call('slow.method');
    bridge.dispose();

    await expect(pending).rejects.toThrow(/disposed/i);
  });
});

Roadmap

| Phase | Scope | |---|---| | v0.1 | Core + createBridge, iframe adapter, Flutter adapter, mock adapter | | Later | aibridgejs-cocos-native — separate package for Cocos JsbBridgeWrapper | | Later | .svelte.ts helper for reactive bridge state in Svelte 5 apps |

aibridgejs-cocos-native is intentionally a separate package because the Cocos native bridge (JsbBridgeWrapper) has thread-safety caveats, an Objective-C reflection path on iOS with App Store review implications, and an independent version lifecycle from the web-focused adapters.


Contributing

All contributions must satisfy the TDD gate cases listed in the Testing section. A PR that passes the gate suite and adds a runnable example for any new behaviour will be reviewed promptly.

Bug reports should include a minimal failing test case using the mock adapter. Security issues should be reported privately before public disclosure.


License

MIT. See LICENSE.