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

web-launch-kit

v0.0.11

Published

TypeScript library for launching external apps and communication intents from the web — deep links / custom schemes, tel, sms, mailto, maps, a file picker, and system-settings deep links, with graceful store/web fallbacks.

Readme

npm bundle size types

English · 한국어

web-launch-kit

A TypeScript library for launching external apps and communication intents from the web. Handles deep links / custom schemes, Android intent:// URLs, iOS universal links, and App Store / web-store fallbacks — trying each candidate in order until one succeeds. Also covers tel, sms, mailto, a file picker, and system-settings deep links.

npm install web-launch-kit

The bundle is self-contained (OS/locale detection is inlined) — no peer scripts required.

API at a glance

LaunchKit is a singleton.

| Member | Signature | Description | | --- | --- | --- | | LaunchKit.version | string | The installed package version | | LaunchKit.debug | boolean (get/set) | Pause on a debugger before each launch attempt; defaults to false | | LaunchKit.SettingType | enum | Setting panes: General, Network, Display, Appearance, Accessibility, Battery, Datetime, Language, Accounts, Storage | | LaunchKit.app(options?) | Promise<AppOpenedBy> | Launch an app via the best available route; resolves with which route opened it | | LaunchKit.telephone(options?) | Promise<void> | Open the dialer (tel:) | | LaunchKit.message(options?) | Promise<void> | Open the SMS composer (sms:) | | LaunchKit.mail(options?) | Promise<void> | Open the mail composer (mailto:) | | LaunchKit.map(options?) | Promise<AppOpenedBy> | Open a map by query, coordinate, or directions; native app per-OS with a Google Maps web fallback | | LaunchKit.filepicker(options?) | Promise<File[]> | Pick files or a directory (File System Access API, with input fallback) | | LaunchKit.setting(type?) | Promise<void> | Open a system-settings pane where supported | | LaunchKit.escapeWebview(options?) | Promise<EscapeWebviewResult> | Reopen a URL (the current page by default) in the external browser, escaping an in-app webview | | LaunchKit.utils | object | canOpenIntent / canOpenUniversal / canOpenSetting / canEscapeWebview / userActivation getters, plus async getTrackId(bundleId) / getProductId(packageFamilyName, language?, country?) |

AppOpenedBy is one of: "scheme", "universal", "intent", "fallback", "store".

app() and map() reject with a LaunchError — a real Error carrying two extra fields:

| Field | Type | Description | | --- | --- | --- | | code | "unsupported-os" \| "no-candidates" \| "all-failed" | Why it failed | | attempted | readonly LaunchAttempt[] | { by, url, index, total } for every candidate tried |


Usage

// ESM
import LaunchKit from 'web-launch-kit'

// CommonJS — the bundle is built with `exports: "named"`
const { default: LaunchKit } = require('web-launch-kit')
<!-- UMD: the global is a namespace object -->
<script src="https://unpkg.com/web-launch-kit/dist/launch-kit.umd.min.js"></script>
<script>
    document.querySelector('#call').addEventListener('click', function () {
        window.LaunchKit.default.telephone({to: '+821012345678'})
    })
</script>

Every option object has a named type, and SettingType is a value export.

import LaunchKit, {SettingType, type AppOpenOptions, type AppOpenedBy} from 'web-launch-kit'

const openedBy: AppOpenedBy = await LaunchKit.app({
  ios: {universal: 'https://example.com/profile/42', bundleId: 'com.example.myapp'},
} satisfies AppOpenOptions)

Launching an app

app() takes per-platform options and only acts on the block matching the current OS. It builds an ordered list of candidates and tries each until one launches the app, resolving with the route that worked.

flowchart TD
    A([LaunchKit.app called]) --> B{Detect OS via PlatformKit}
    B -->|unknown| E1([Reject: unsupported OS])
    B -->|android| AND
    B -->|ios| IOS
    B -->|windows| WIN
    B -->|macos| MAC

    subgraph AND["android · resolveOptions"]
        A1{"intent given, but scheme /<br/>packageName / fallback missing?"}
        A1 -->|yes| A2["parseIntentURL:<br/>derive scheme · packageName · fallback"]
        A1 -->|no| A3
        A2 --> A3{"allowIntent, and scheme given<br/>but intent missing?"}
        A3 -->|yes| A4["createIntentURL:<br/>scheme + packageName + fallback"]
        A3 -->|no| A5
        A4 --> A5["Priority list:<br/>intent (if allowIntent and canOpenIntent) ⭢ scheme (if canOpenScheme)<br/>⭢ fallback ⭢ app store (if canOpenScheme) ⭢ web store"]
    end

    subgraph IOS["ios · resolveOptions"]
        I1{"bundleId given, but trackId missing?"}
        I1 -->|yes| I2["getTrackId:<br/>iTunes lookup API (bundleId ⭢ trackId)"]
        I1 -->|no| I3
        I2 --> I3["Priority list:<br/>universal (if canOpenUniversal) ⭢ scheme (if canOpenScheme)<br/>⭢ fallback ⭢ app store (if canOpenScheme) ⭢ web store"]
    end

    subgraph WIN["windows · resolveOptions"]
        W1{"packageFamilyName given,<br/>but productId missing?"}
        W1 -->|yes| W2["getProductId:<br/>packageFamilyName ⭢ productId"]
        W1 -->|no| W3
        W2 --> W3["Priority list:<br/>scheme ⭢ fallback<br/>⭢ app store ⭢ web store (by productId)"]
    end

    subgraph MAC["macos · resolveOptions"]
        M1{"bundleId given, but trackId missing?"}
        M1 -->|yes| M2["getTrackId:<br/>iTunes lookup API (bundleId ⭢ trackId)"]
        M1 -->|no| M3
        M2 --> M3["Priority list:<br/>scheme ⭢ fallback<br/>⭢ app store ⭢ web store (by trackId)"]
    end

    subgraph LOC["web store · createWebStoreURL"]
        L1{"country given?"}
        L1 -->|yes| L3["Play · MS Store: hl / gl parameters<br/>App Store: /country/ path + l parameter"]
        L1 -->|no| L2["Derive from language:<br/>region subtag ⭢ Intl.Locale.maximize()<br/>⭢ omit (store geolocation)"]
        L2 --> L3
    end

    A5 -.->|"web store"| L1
    I3 -.->|"web store"| L1
    W3 -.->|"web store"| L1
    M3 -.->|"web store"| L1

    AND --> P
    IOS --> P
    WIN --> P
    MAC --> P

    P{"Any URL candidates?"} -->|no| E2([Reject: no openable URL candidates])
    P -->|yes| Q{"Next candidate type?"}
    Q -->|"function fallback"| R["Invoke fallback function"] --> G
    Q -->|"URL string"| O["openURL(index, url, timeout)"]
    O --> F{Opened?}
    F -->|yes| G(["Resolve AppOpenedBy:<br/>'intent' · 'universal' · 'scheme' · 'fallback' · 'store'"])
    F -->|no| H{"Candidates remaining?"}
    H -->|yes| Q
    H -->|no| E3(["Reject: all attempted URLs failed<br/>(error lists every tried URL)"])

    subgraph openURL["openURL · app-switch detection"]
        T0["Register blur + visibilitychange listeners"] --> T1{"Document focused?"}
        T1 -->|no| T2["restoreFocus:<br/>window ⭢ body ⭢ hidden input"]
        T1 -->|yes| T3
        T2 --> T3{"Environment?"}
        T3 -->|cordova| T4["InAppBrowser.open / window.open ('_system')"]
        T3 -->|browser| T5{"userActivation active<br/>or first attempt?"}
        T5 -->|yes| T6["top.location.href = url"]
        T5 -->|no| T7["Hidden anchor + synthetic click"]
        T6 --> T8["+ hidden iframe (removed after 500ms)"]
        T7 --> T8
        T4 --> T9
        T8 --> T9{"blur / hidden fired?"}
        T9 -->|"yes ⭢ wait focus"| T10([resolve: app opened])
        T9 -->|"no, until timeout"| T11([reject: app not detected])
    end

    O -.->|delegates to| T0
import LaunchKit from 'web-launch-kit'

const openedBy = await LaunchKit.app({
  android: {
    scheme: 'myapp://profile/42',
    packageName: 'com.example.myapp',
    allowAppStore: true,
    allowWebStore: true,
    language: 'ko', // web-store UI language
    country: 'KR',  // omit to derive from language (region subtag → Intl.Locale.maximize())
  },
  ios: {
    universal: 'https://example.com/profile/42',
    scheme: 'myapp://profile/42',
    bundleId: 'com.example.myapp', // resolved to a trackId for the store fallback
    allowAppStore: true,
  },
})

console.log(openedBy) // "universal" | "scheme" | "intent" | "fallback" | "store"

Per-platform fields: Android accepts intent / scheme / packageName / fallback / allowIntent (scheme ⇄ intent are derived from each other); iOS accepts universal / scheme / bundleId / trackId; Windows accepts scheme / packageFamilyName / productId; macOS accepts scheme / bundleId / trackId. All accept fallback, timeout, allowAppStore, allowWebStore, language, country.

language / country localize the web-store candidate only — hl / gl on Google Play and the Microsoft Store, a country path segment plus l on the App Store. An omitted country is derived from language (region subtag first, then Intl.Locale.maximize()), and dropped entirely when neither yields one, since a wrong country lands on a "not available in your region" page. Native store schemes are untouched: the OS localizes those.

onAttempt fires synchronously before each candidate, which is where you drive the waiting UI — there is otherwise nothing on screen for the length of the timeout.

await LaunchKit.app({
  android: { scheme: 'myapp://profile/42', packageName: 'com.example.myapp', allowWebStore: true },
  onAttempt({ by, index, total }) {
    setStatus(by === 'store' ? 'Go to the store' : 'Opening the app… (' + (index + 1) + '/' + total + ')')
  },
})

A second call while one is still running returns the same promise, so a double tap cannot start a second chain that navigates over the first.

Android and iOS additionally accept assumeAllowedInApp — declare it true only when your app is whitelisted by a partner-gated in-app browser (e.g. Weibo) so scheme / universal-link candidates are kept there; it never bypasses hard-blocked webviews or OS version requirements.

Communication intents

import LaunchKit from 'web-launch-kit'

await LaunchKit.telephone({ to: '+821012345678' })

await LaunchKit.message({ to: '+821012345678', body: 'hello' })

await LaunchKit.mail({
  to: ['[email protected]', '[email protected]'],
  cc: '[email protected]',
  subject: 'Hi',
  body: 'from web-launch-kit',
})

File picker

import LaunchKit from 'web-launch-kit'

// Files (uses showOpenFilePicker where available, falls back to <input type=file>)
const files = await LaunchKit.filepicker({ accept: ['image/*', '.pdf'], multiple: true })

// A directory (recursive; webkitRelativePath is populated)
const tree = await LaunchKit.filepicker({ directory: true })

Cancelling resolves with []. Engines with a cancel event report it immediately; the rest are detected by regaining focus with an empty value, which takes a moment longer.

Maps

map() takes an intent — a search query, a coordinate, or a route — and builds the right URL for the current OS (maps:// on iOS/macOS, geo: on Android, bingmaps: on Windows), falling back to Google Maps on the web.

import LaunchKit from 'web-launch-kit'

// Search for a place
await LaunchKit.map({ query: 'Seoul City Hall' })

// Show a coordinate with a labelled pin
await LaunchKit.map({ coordinate: [37.5665, 126.9780], label: 'Seoul City Hall', zoom: 15 })

// Directions (origin defaults to current location when omitted)
await LaunchKit.map({ directions: { destination: 'Seoul Station', origin: [37.5665, 126.9780] } })

Provide exactly one of query / coordinate / directions; when several are set, directions wins, then coordinate, then query. A label with coordinate renders a named pin on iOS/macOS/Windows and is ignored on Android, where geo: handlers other than Google Maps misread the labelled form as a search. Android's geo: scheme also has no standard directions support, so routes use the Google Maps fallback there, and on Windows the discontinued Maps app means bingmaps: usually defers to the web fallback too.

Testing from the console

A call from the DevTools console has no focus and no user activation, so it fails and app-switch detection has nothing to observe. debug pauses on a debugger before the navigation; pressing resume hands both back, so the console behaves like a real tap.

LaunchKit.debug = true

await LaunchKit.app({ ios: { scheme: 'myapp://profile/42' } })

Leave it off otherwise — it pauses once per candidate, in every launch method.

System settings

import LaunchKit from 'web-launch-kit'

if (LaunchKit.utils.canOpenSetting) {
  await LaunchKit.setting(LaunchKit.SettingType.Network)
}

Escaping in-app browsers

escapeWebview() reopens a URL — the current page by default — in the external default browser, escaping the in-app webview the page is trapped in.

import LaunchKit from 'web-launch-kit'

if (LaunchKit.utils.canEscapeWebview) {
  await LaunchKit.escapeWebview()
}

// Or send a specific URL out instead of the current page
await LaunchKit.escapeWebview({ url: 'https://example.com/checkout' })

It resolves with which door it used and whether leaving was actually seen:

{ route: 'kakaotalk' | 'line' | 'intent' | 'x-safari', url: string, verified: boolean }

verified is false for LINE, whose route navigates this very page to an http(s) URL: an app that ignores the hint looks exactly like one that honors it, so the escape was issued, not confirmed.

The route depends on the environment. KakaoTalk exposes a dedicated kakaotalk://web/openExternal scheme and LINE honors the documented openExternalBrowser=1 query parameter — both work on Android and iOS. Other webviews fall back to the OS route: a Chrome-pinned intent:// URL on Android, and the undocumented x-safari- scheme on iOS — which works on iOS 15 and 17+ but not 16, always opens Safari regardless of the default browser, and is blocked inside Meta's webviews (Facebook, Instagram).

The Android intent carries no S.browser_fallback_url. The only URL there would be to fall back to is the page issuing the escape, and a webview that cannot open the intent would then reload it and escape again, without end. Without the key a failed intent leaves the page where it stands and the promise rejects with escape-failed, which is the caller's cue to open the app in place.

For the same reason an escape that has just happened is not repeated:

await LaunchKit.escapeWebview({ cooldown: 5000 })  // default 3000, 0 disables

escape-cooldown is raised only for the exact shape of the loop — the same URL, within the window, with the engine positively reporting that no gesture has been spent. Everything else passes: a different URL, an engine that does not report activation, and above all a reader who presses something, since that press carries a transient activation. A page reopened by an escape that landed back on itself is refused; a person asking again is not.

Only http(s) URLs can be reopened. Outside a webview, or where no route is available (hard-blocked webviews, iOS 16, desktop), the promise rejects — gate the button on utils.canEscapeWebview.


Notes

  • Deep links are most reliable from a real user gesture. Call app() from a click handler where you can. Activation is read as a hint about the route, never as a verdict on whether to try — app() always attempts. A top-level navigation reaches the OS with no activation at all; only the anchor and iframe routes are refused without a gesture. utils.userActivation ('active' | 'sticky' | 'none' | 'unknown') exposes the reading.
  • app() resolves with the route, not a guarantee of launch. Detection is a focus/visibility heuristic on a per-OS timeout: a resolved AppOpenedBy means that candidate was attempted and the page appeared to background.
  • Pre-warm store ids. utils.getTrackId / getProductId hit remote APIs (iTunes Lookup, Microsoft display catalog), cache for an hour, and resolve to undefined on failure. Call one during page setup, or pass trackId / productId directly — inside app() a missing id is looked up synchronously rather than awaiting and losing the gesture. getProductId takes the same optional language / country you give app(), since a lookup can succeed in one market and not another.
  • In-app browsers are gated per candidate. Inside webviews that block launches (WeChat, QQ, Qzone, Baidu; Weibo unless partnered), dead candidates are skipped up front instead of burning their timeouts, so the chain falls straight to fallback / web store. Whitelisted partner apps opt back in with assumeAllowedInApp: true.
  • allowIntent: false drops the Android intent:// route. A derived intent carries S.browser_fallback_url, so the browser handles that hop itself on the first attempt: fastest route to the store, but the first attempt always lands somewhere and the plain scheme behind it never runs. Turning it off lets the chain decide the order instead: scheme, then fallback, then the store. An explicit intent is gated too, though the scheme, packageName and fallback parsed out of it are still used.
  • Navigating candidates resolve on navigation. A fallback or web-store candidate replaces the document, so there is no app switch to wait for. The same goes for a candidate that turns into an unplanned navigation — a universal link whose app is not installed, an intent whose browser fallback fires: pagehide resolves it, and since an app switch fires hidden without pagehide, the two stay distinguishable.

Browser support

Runs down to IE 9 — which has no Promise, so supply a polyfill there: every method returns one.