inappwebview_script_guard

Detect silently-failed JavaScript injection in flutter_inappwebview: canary-verified script loading at runtime, plus commit-time AST linting of the JavaScript you embed in Dart files.

Scope, honestly: this is a flutter_inappwebview package, not an "any WebView" abstraction. The verifier calls InAppWebViewController.evaluateJavascript directly. The commit-time tooling (invariants.dart + the executables + the example hooks) is WebView-agnostic — it lints Dart files — but the runtime canary is coupled to flutter_inappwebview by design.

The problem

evaluateJavascript can swallow failures. A script with a parse error — or one that throws mid-execution — can resolve the Dart future normally, report nothing but Script error. @:0:0 on window.onerror (if anything), and leave your app believing the script loaded. The classic trigger is two adjacent Dart string literals ('foo ' 'bar') inside an embedded script: Dart concatenates them silently, V8 rejects the result, and every feature depending on that script degrades quietly — for days, if nothing distinguishes "script ran" from "script never parsed".

Three failure classes, three different corrective actions — and without instrumentation they are indistinguishable:

  1. Script-side failure — parse error, mid-execution throw, page navigated away mid-run. Action: investigate the script — but the same missing verdict also arises when a navigation wiped the world after a healthy run, where the action is the opposite (re-inject once); see The missing verdict.
  2. Bridge-side failureevaluateJavascript itself threw or hung (navigation mid-eval, page disposal, bad ContentWorld). Action: investigate/report the bridge anomaly.
  3. Stale script — the WebView is running a cached older version. Action: reload / invalidate cache.

This package makes the three distinguishable.

Quick start (runtime canary)

import 'package:inappwebview_script_guard/inappwebview_script_guard.dart';

// 1. Wrap your literal JS in a JsScript. Keep the payload a raw
//    triple-quoted string — no interpolation, no concatenation.
final extractionScript = JsScript(
  name: 'extraction',
  source: r'''
JSON.stringify((function () {
  // ... your script ...
  return {ok: true};
})());
''',
);

// 2. Inject + verify in one call.
final (canary, bridgeRaw) =
    await injectAndVerifyCanary(controller, extractionScript);

// 3. Route on the verdict.
switch (canary) {
  case LoadedResult():
    final payload = jsonDecode(bridgeRaw as String);
  case MissingResult():
    // parse error / mid-run throw — or a navigation wiped the world;
    // rule out the wipe (re-inject once) before debugging the JS
  case InjectThrewResult(:final exception, :final stackTrace):
    // bridge-side failure: report exception + stackTrace
  case StaleResult():
    // cached older script: reload or invalidate cache
  case MalformedResult():
    // some other JS clobbered the flag: investigate the writer
}

How the canary works

JsScript.withCanary wraps your source as:

;(function(){
  var __canary_result=(function(){return <SOURCE>
  })();
  window['__canaryLoaded_<name>']='<sha256-first-8>';
  return __canary_result;
})();

Three properties are load-bearing (each is locked by a test, and each was violated at some point by a wrap shape that "looked obviously correct"):

  • The script's own return value still reaches the bridge. The inner IIFE returns your source's value; the outer IIFE captures it and returns the capture — not the canary assignment. Consumers that decode bridgeRaw keep working.
  • The canary flag is written after the result assignment, in normal control flow — never in finally. If the bridge swallows a mid-script throw (the exact failure this package exists to detect), a finally-based flag would still fire and report loaded for a script that died. Flag set ⟹ source completed, regardless of bridge swallowing behavior.
  • The flag value is a content hash, not true. A flag holding the SHA-256-first-8 of the source distinguishes "loaded the version I shipped" from "loaded something" — which is what turns cache staleness into a first-class verdict.

Verdicts

Verdict Meaning Corrective action
loaded Flag present, hash matches none — healthy
missing Flag absent two very different causes — rule out a navigation wipe (re-inject once) before investigating the JS; see The missing verdict
injectThrew evaluateJavascript threw or timed out (exception + stack preserved on the result) bridge-side: report / retry
stale Flag is valid 8-hex but differs reload / invalidate WebView cache
malformed Flag set to a non-hash value some other JS wrote to the flag — investigate the writer, do NOT reload

CanaryResult is a sealed hierarchy: canary is InjectThrewResult narrows statically, so exception / stackTrace are non-nullable at the read site with no guards.

Configuration

  • canaryPrefix (JsScript, default '__canaryLoaded') — the window property prefix. The full flag is '<prefix>_<name>', exposed as script.canaryFlagName — the single source of truth both the wrap and the read-back derive from.
  • evalTimeout (verifyCanary / injectAndVerifyCanary, default 10 s) — bounds every evaluateJavascript await. A hung bridge call (mid-navigation stall; iOS's async-only eval) routes to injectThrew with a TimeoutException instead of hanging your capture pipeline. Note injectAndVerifyCanary performs two awaits (inject + read-back), so its worst case is 2× this value.
  • contentWorld — threads to both the inject and the read-back, so isolated-world injections read the flag from the world it was written in. (A shape that threads it only to inject verifies as spuriously missing.)

Async scripts

If your source returns a Promise, the canary fires when the synchronous prologue completes — it does not wait for the Promise to settle. loaded means "the script started and returned a Promise," not "the async work succeeded." Check both the verdict and the awaited result. This is deliberate: awaiting would couple the canary to user-page network state.

The missing verdict: two causes, opposite responses

missing means the flag never appeared. That has two cause classes, and the corrective actions point in opposite directions:

  1. Script-side failure — a parse error mid-execution, or the body threw before the canary tail ran. Re-injecting reproduces the failure. Investigate the JS (window.onerror, a log line at the top of the script to disambiguate "never started" from "started but threw").
  2. Navigation wipe — the page navigated between the script running and your read-back: a redirect, or a WebView restoring its session and committing the real document after an early onLoadStop already fired. The script ran and the flag was written (the two are one synchronous eval — "flag set ⟹ source completed" still holds), but into a document that was then replaced, so the read-back queries a fresh JS world that never had it. (A navigation landing mid-run kills the script before the flag write — cause 1's shape — but the corrective action below is the same either way.) This is easy to hit with the standard inject-at-onLoadStop pattern, and on pages where no second onLoadStop follows (session restore, some SPAs), a one-shot inject stays dead until something re-injects.

For cause 2, "investigate the JS" is a dead end — the fix is to re-inject once when the page has settled. A bounded recovery shape:

var pageGeneration = 0; // bump on every page load AND on teardown

Future<void> injectWithRecovery(
    InAppWebViewController controller, JsScript script,
    {ContentWorld? contentWorld}) async {
  final gen = ++pageGeneration;
  final (verdict, _) = await injectAndVerifyCanary(controller, script,
      contentWorld: contentWorld);
  if (verdict is! MissingResult) return; // any non-missing verdict: never retry
  unawaited(Future<void>.delayed(const Duration(seconds: 5)).then((_) async {
    if (pageGeneration != gen) return; // a later load re-injected anyway
    if (scriptSignalsObserved) return; // it was alive all along
    final (retry, _) = await injectAndVerifyCanary(controller, script,
        contentWorld: contentWorld);
    if (pageGeneration != gen) return;
    debugPrint(retry.isHealthy
        ? 'script re-inject recovered (canary ok)'
        : 'script re-inject still unhealthy: $retry');
    // ONE attempt only — a second miss is a genuine script-side
    // failure (or a hostile page); log it and stop.
  }));
}

The guards matter as much as the retry:

  • Generation / session counter — bump it on every navigation and on teardown. Future.delayed has no cancel handle, so without the teardown bump a pending retry fires into a disposed controller.
  • scriptSignalsObserved — whatever your script emits (JS-handler messages, samples). If signals are flowing, the first inject actually survived and the missing verdict was a read-back race — re-evaluating a live script would double its listeners and timers.
  • Retry missing only. stale means a script IS running (an older version — re-evaluating doubles listeners; invalidate the cache instead), and injectThrew is bridge-side (a re-eval doesn't fix the bridge).
  • Thread your contentWorld through both calls (as above). A retry that omits it runs — and verifies — in the default world, reporting a verdict about a world your isolated-world script never touched.
  • Bound it to one attempt. Unbounded retries against a page that genuinely blocks your script spin forever.

Two layers of defense

Parse errors are guarded twice — once before they ship, once at runtime:

Commit time (static): parse validation — lives in package:inappwebview_script_guard/invariants.dart, the bundled executables, and the example hook scripts.

  • checkSource / dart run inappwebview_script_guard:check_js_invariants — AST-only invariant checker (sub-second, no Node). Rejects the Dart-level constructs that silently corrupt embedded JS before they ever reach a device.
  • dart run inappwebview_script_guard:extract_js + node --check — extracts every raw triple-quoted literal and parse-checks it with real V8, so anything the AST rules can't prove is caught by an actual JS parser.

Runtime: the canary — catches what static checks structurally can't see: mid-execution throws, runtime DOM-shape mismatches, stale-cache shipping. It is also the backstop for parse failures that somehow get past commit time: an unparseable script never reaches the canary write, so it reads back as missing.

Design note — why nothing sits between them. An in-between layer is conceivable: wrap every payload in eval("…") at runtime so parse errors propagate as catchable exceptions. This package deliberately doesn't do that — it would add an eval indirection to every script, and it detects nothing the two layers above don't already cover (commit-time node --check catches the parse error before it ships; the runtime canary reports it as missing if it ships anyway).

The four rejected constructs (commit time)

Keep raw JS payloads in a dedicated scripts directory and point the checker at it (--root <dir> — nothing about the directory name is hard-coded). Files there are restricted to literal JS in raw triple-quoted strings. The AST checker rejects:

Construct Example Why
Non-raw multi-line '''alert($x)''' $x is Dart interpolation; ships broken JS
Interpolation in multi-line '''$name''' (no r) same as above
Adjacent literals 'foo ' 'bar' Dart concatenates silently; the WebView sees invalid JS — the classic silent-failure shape
String + on literals 'foo' + 'bar' hidden composition; belongs in a composers directory

Composed JS (templating, conditionals) belongs in a separate composers directory outside the checked root, with unit tests on the composed output.

Per-file skip directive

A file awaiting migration can opt out with a top-of-file directive (first 10 lines):

// CHECK_JS_INVARIANTS_SKIP_FILE: <reason> #<issue>

Both fields are required — a malformed directive fails the check rather than silently exempting the file, and skipped files are printed loudly so review sees them. #<issue> refers to your own tracker.

Git-hook wiring (example scripts, not package API)

The example/hooks/ directory ships three copy-in scripts:

  • pre-commit — runs the AST invariants on staged scripts-root files (sub-second).
  • check_js.sh — the full sweep: AST invariants + node --check, then writes a proof-of-work JSON keyed to the SHA-256 of the entire scripts root at HEAD (CRLF-normalized, so Windows and Linux hash identically).
  • pre-push — verifies the proof matches the pushed tree's bytes. Honest-system enforcement: it doesn't re-validate, it confirms validation happened against exactly what ships.

Setup:

flutter pub add inappwebview_script_guard          # the hooks run the package's
                                             # executables via `dart run`
cp example/hooks/pre-commit example/hooks/pre-push .githooks/
cp example/hooks/check_js.sh tool/
chmod +x .githooks/pre-commit .githooks/pre-push tool/check_js.sh
git config core.hooksPath .githooks
export JS_SCRIPTS_ROOT=lib/webview_scripts   # or edit the scripts
echo .js-check-state/ >> .gitignore          # proof files are local state
tool/check_js.sh                             # first run: 0 violations

(chmod +x matters: git silently ignores a non-executable hook — no error, the hook just never runs.)

These are examples on purpose: hook policy (bypass variables, proof location, which gates chain together) is repo policy, not package API. Edit them freely.

Testing your integration

The package's own suite pins the wrap format, the verdict routing, the timeout, and the AST rules — you don't need to re-test those. What's worth testing in your repo:

  • a parse test per JS-bearing file (extract + node --check in CI or hooks, as above);
  • your routing of each verdict to the right corrective action.

For fakes, implement InAppWebViewController with noSuchMethod throwing and override only evaluateJavascript — see this package's test/canary_verifier_test.dart for the pattern.

When something fails

  • Pre-commit fails with "adjacent string literals": the silent-concatenation shape. Restructure, or move the composition to a composers directory.
  • Pre-push fails with "proof stale" / "no proof": the scripts root changed since the last check_js.sh run (or it never ran). Re-run it.
  • Canary missing at runtime: commit-time checks passed but the flag never appeared. First rule out a navigation wipe (redirect / session restore between inject and flag write) — re-inject once when the page settles; see The missing verdict. If it recurs, it's script-side: a runtime throw (check window.onerror); a log line at the top of the script disambiguates "never started" from "started but threw".
  • Canary stale at runtime: the WebView is running a cached copy. Force a reload or invalidate the cache.
  • Canary malformed: something else wrote to your flag — don't reload; find the writer (or change canaryPrefix).

License

MIT.

Libraries

inappwebview_script_guard
Detect silently-failed JavaScript injection in flutter_inappwebview.
invariants
Commit-time AST linting for Dart files that embed literal JavaScript.