@deepthix/inapp-escape
v0.1.0
Published
Escape in-app browsers (Instagram, Facebook, TikTok, X, LinkedIn…) and open links in the user's real browser. Cloudflare Worker + framework-agnostic core. Built by Deepthix.
Maintainers
Readme
@deepthix/inapp-escape
Escape in-app browsers. Open links in the user's real browser.
Stop losing users to Instagram, TikTok, Facebook & X webviews. A tiny, dependency-free helper that ships everywhere.
Built by Deepthix — we ship AI agents that meet users where they are. We hit this problem in production and packaged the fix so you don't have to.
The problem
When users tap your link inside Instagram, TikTok, or Facebook, the link opens in a captive in-app browser — a stripped-down WebView that:
- Has no extension support, no password manager, no dev tools
- Doesn't share cookies or localStorage with the user's real browser
- Breaks OAuth flows ("Sign in with Google" refuses to load)
- Tanks conversion: industry data shows 30–60% drop-off vs. system browser
Worse, every app has different rules. Instagram blocks intent:// redirects.
Twitter/X uses SFSafariViewController which ignores most schemes. Facebook
honors x-safari-https:// but Instagram doesn't.
This package handles the matrix for you.
What it does
| App | Strategy |
|---|---|
| Instagram, Threads (iOS) | instagram://extbrowser/?url=… — the IG app itself opens Safari |
| Facebook, Messenger (iOS) | x-safari-https://… — auto-redirect via the FB WebView |
| Twitter/X, TikTok, LinkedIn, Snap, Pinterest, Reddit (iOS) | Gesture-required button (their WebViews block JS auto-redirects) |
| All apps on Android | intent://…#Intent;scheme=https;…end — opens in the user's default browser |
Plus:
- Loop guard — fallback URL carries
?_inapp_escaped=1so the interstitial doesn't re-trigger itself - Static assets skipped —
.css,.js,/api/,/_next/, etc. pass straight through - Loader + CTA UI — clean, language-neutral, accessible interstitial
- Zero dependencies — pure TypeScript, ships ESM, ~3 KB gzipped
Install
npm install @deepthix/inapp-escapeQuick start (Cloudflare Worker)
The intended deployment: a Worker fronting your origin domain.
// worker.ts
import { createWorker } from "@deepthix/inapp-escape/cloudflare";
export default createWorker();Deploy with wrangler and route your domain through it. Done.
Quick start (Express)
import express from "express";
import { handle } from "@deepthix/inapp-escape";
const app = express();
app.use((req, res, next) => {
const fullUrl = `${req.protocol}://${req.headers.host}${req.originalUrl}`;
const result = handle({
url: fullUrl,
userAgent: req.headers["user-agent"] ?? "",
method: req.method,
accept: req.headers.accept ?? "",
});
if (!result) return next();
for (const [k, v] of Object.entries(result.headers)) res.setHeader(k, v);
res.status(result.status).send(result.html);
});Quick start (Next.js middleware)
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { handle } from "@deepthix/inapp-escape";
export function middleware(req: NextRequest) {
const result = handle({
url: req.url,
userAgent: req.headers.get("user-agent") ?? "",
method: req.method,
accept: req.headers.get("accept") ?? "",
});
if (!result) return NextResponse.next();
return new NextResponse(result.html, { status: result.status, headers: result.headers });
}More examples in examples/.
API
handle(input, options?) => HandleResult | null
Framework-agnostic core. Inspects request strings and returns either a
plan (HTML + status + headers) or null if the request should pass through.
const result = handle({
url: "https://example.com/landing",
userAgent: req.headers["user-agent"] ?? "",
method: req.method, // optional, default "GET"
accept: req.headers.accept, // optional
});
if (result) {
// result.html, result.status, result.headers
// result.app — "instagram" | "facebook" | …
// result.platform — "ios" | "android"
// result.gestureOnly — whether the page requires a tap
// result.primary — the custom-scheme URL
// result.fallbackUrl — the https URL the page falls back to
}createWorker(options?) => ExportedHandler
Cloudflare Worker default-export factory. Wraps handle() and forwards
non-intercepted requests to the origin.
Options
interface HandleOptions {
/** Path prefixes to skip. Default: ["/api/", "/_next/", "/static/", …] */
skipPathPrefixes?: ReadonlyArray<string>;
/** Regex to skip static asset extensions. Sensible default included. */
skipExtensions?: RegExp;
/** ms before the auto-redirect falls back to https. Default 1500. */
fallbackDelayMs?: number;
/** Footer label. `false` to hide. Default "Powered by Deepthix". */
branding?: string | false;
/** CTA button label. Default "Continue". */
buttonLabel?: string;
/** Override which apps require a user-gesture (no auto-redirect). */
needsUserGesture?: ReadonlySet<App>;
/** Logging hook. Default `console.log`. */
onEvent?: (e: HandleEvent) => void;
}Lower-level exports
import {
detect, // (ua) => { app, platform }
detectApp, // (ua) => App | null
detectPlatform, // (ua) => "ios" | "android" | "other"
iosScheme, // (app, url) => string
androidIntent, // (url) => string
renderEscapePage, // (opts) => string — raw HTML if you want to bring your own response layer
NEEDS_USER_GESTURE, // ReadonlySet<App>
ESCAPED_FLAG, // "_inapp_escaped"
} from "@deepthix/inapp-escape";Why does this work?
The key insight is that every in-app webview has a different escape hatch:
- Instagram's WKWebView blocks
window.location = "x-safari-https://…"without a user gesture. But Instagram registered its own schemeinstagram://extbrowser/?url=…that the Instagram app itself intercepts (not the WebView) and forwards to Safari. This bypasses the gesture requirement entirely. - Facebook's WebView is more permissive — it honors
x-safari-https://from JS. - Twitter/X uses
SFSafariViewController, which is itself a Safari-engine view. Custom schemes from JS are silently dropped. Only a user-tapped<a href>creates the gesture context the controller forwards to the system. - Android has had
intent://URIs forever — but hardcodingpackage=com.android.chromefails on devices without Chrome. We omit the package and rely on the user's default.
This package encodes that matrix so you don't have to rediscover it.
Limitations (be honest)
- Twitter/X iOS: when X uses
SFSafariViewController, even a user tap may not escape on every iOS version. The interstitial shows a clear button — beyond that, Apple's sandbox wins. taap.it, smartlinkfox, and friends hit the same wall. - Universal Links: if your domain has an iOS app with a registered associated
domain, in-app browsers may bounce to your app instead of escaping. Disable the
apple-app-site-associationfor the relevant paths if that's not what you want. - WebView versions: app vendors change webview behavior over time. We track
the matrix in
src/schemes.ts— PRs welcome.
About Deepthix
Deepthix builds AI agents that work where users are — which means we deal with web platform reality, not idealized SPAs. We open-source the infra fixes we need internally so the whole ecosystem benefits.
If this package saved you a week of debugging, give it a ⭐ and check out what else we're building at deepthix.com.
Contributing
Bug reports, scheme additions, and adapter PRs (Hono, Fastify, AWS Lambda…) very welcome. Open an issue first for non-trivial changes.
License
MIT © Deepthix
