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_inappwebviewpackage, not an "any WebView" abstraction. The verifier callsInAppWebViewController.evaluateJavascriptdirectly. 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:
- Script-side failure — parse error, mid-execution throw, page
navigated away mid-run. Action: investigate the script — but the
same
missingverdict also arises when a navigation wiped the world after a healthy run, where the action is the opposite (re-inject once); see Themissingverdict. - Bridge-side failure —
evaluateJavascriptitself threw or hung (navigation mid-eval, page disposal, badContentWorld). Action: investigate/report the bridge anomaly. - 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 decodebridgeRawkeep 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), afinally-based flag would still fire and reportloadedfor 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') — thewindowproperty prefix. The full flag is'<prefix>_<name>', exposed asscript.canaryFlagName— the single source of truth both the wrap and the read-back derive from.evalTimeout(verifyCanary/injectAndVerifyCanary, default 10 s) — bounds everyevaluateJavascriptawait. A hung bridge call (mid-navigation stall; iOS's async-only eval) routes toinjectThrewwith aTimeoutExceptioninstead of hanging your capture pipeline. NoteinjectAndVerifyCanaryperforms 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 spuriouslymissing.)
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:
- 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"). - 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
onLoadStopalready 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-onLoadStoppattern, and on pages where no secondonLoadStopfollows (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.delayedhas 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 themissingverdict was a read-back race — re-evaluating a live script would double its listeners and timers.- Retry
missingonly.stalemeans a script IS running (an older version — re-evaluating doubles listeners; invalidate the cache instead), andinjectThrewis bridge-side (a re-eval doesn't fix the bridge). - Thread your
contentWorldthrough 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 --checkin 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.shrun (or it never ran). Re-run it. - Canary
missingat 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 Themissingverdict. If it recurs, it's script-side: a runtime throw (checkwindow.onerror); a log line at the top of the script disambiguates "never started" from "started but threw". - Canary
staleat 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 changecanaryPrefix).
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.