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

@cantoo/capacitor-http-server

v1.0.8

Published

Capacitor plugin exposing a local HTTP server on iOS and Android with routing delegated to JavaScript.

Readme

@cantoo/capacitor-http-server

A generic Capacitor plugin that exposes a local HTTP server on iOS and Android with routing delegated to JavaScript.

The plugin has no business logic. It only handles the transport layer: it starts a listener, hands every incoming request to JavaScript via a request event, and writes back whatever JavaScript returns through respond(). This keeps your app code — authentication, routing, payloads, CORS policy — entirely in TypeScript, where it can be tested, updated from a hot-reload bundle, and shared across platforms.

Changelog

1.0.7

  • StartResult adds addresses / urls (IPv4 LAN + routable IPv6, bracketed IPv6 URLs). port / url / localIp stay the historical IPv4 LAN fields.
  • Android NanoHTTPD binds dual-stack (::, IPv4 fallback). iOS GCDWebServer already listens on IPv4 + IPv6.

Install

npm install @cantoo/capacitor-http-server
npx cap sync

Android configuration

The plugin ships all required permissions in its manifest:

  • INTERNET, ACCESS_NETWORK_STATE, ACCESS_WIFI_STATE
  • FOREGROUND_SERVICE and FOREGROUND_SERVICE_DATA_SYNC (API 34+)
  • POST_NOTIFICATIONS (API 33+)

The server runs inside a foreground service with type dataSync, so it survives the screen being locked. On Android 13+ the system requires a visible notification, hence POST_NOTIFICATIONS. If the user declines the permission, the server still starts but the system is free to kill the process at any moment. The plugin requests the permission automatically on the first start() call.

You must supply android.notificationTitle and android.notificationText when starting the server:

await HttpServer.start({
  android: {
    notificationTitle: 'My App',
    notificationText: 'Local server is running.',
    smallIconResourceName: 'ic_notification', // res/drawable/ic_notification.png
    channelId: 'my_app_http',
    channelName: 'Local HTTP server',
  },
});

iOS configuration

Add the following keys to your app's Info.plist:

<key>NSLocalNetworkUsageDescription</key>
<string>Used to expose a local server for sharing files across devices on this network.</string>

<!-- Optional. Only needed if you broadcast your server over Bonjour. -->
<key>NSBonjourServices</key>
<array>
  <string>_myservice._tcp</string>
</array>

Without NSLocalNetworkUsageDescription, iOS 14+ silently drops every incoming connection from other devices.

Note: iOS does not allow long-running HTTP servers in background. The plugin requests a short background task on app backgrounding, then emits a server-error event with fatal: true when the window expires. Restart the server on UIApplication.didBecomeActive.

Usage

Echo server

A complete, 20-line echo server that returns exactly what it received:

import { HttpServer } from '@cantoo/capacitor-http-server';

await HttpServer.addListener('request', async (req) => {
  await HttpServer.respond({
    requestId: req.requestId,
    status: 200,
    headers: { 'content-type': req.headers['content-type'] ?? 'text/plain' },
    ...(req.bodyText !== undefined && { bodyText: req.bodyText }),
    ...(req.bodyBase64 !== undefined && { bodyBase64: req.bodyBase64 }),
    ...(req.bodyFilePath !== undefined && { bodyFilePath: req.bodyFilePath }),
  });
});

const { url, urls } = await HttpServer.start({
  android: {
    notificationTitle: 'Echo server',
    notificationText: 'Listening for requests',
  },
});
console.log('Primary IPv4 URL', url);
console.log('All candidate URLs', urls);

Test it from a laptop on the same network:

curl http://<device-ipv4>:<port>/ -d 'hello'
# hello

IPv6 / multi-address clients

start() always returns the historical IPv4 fields (url, localIp) plus two arrays for multi-homed / IPv6 discovery:

const { port, url, localIp, addresses, urls } = await HttpServer.start({ /* … */ });

// Prefer `urls` when encoding a QR or probing reachability (e.g. GET /health).
// IPv6 entries are already bracketed: "http://[2001:db8::1]:18765"
for (const candidate of urls) {
  console.log(candidate);
}
  • addresses — bare IPs only (no scheme/port), sorted by score (Wi‑Fi IPv4 → Ethernet → hotspot → IPv6 Wi‑Fi → IPv6 cellular, …).
  • Excludes loopback and link-local (169.254.0.0/16, fe80::/10).
  • Includes ULA (fc00::/7) when present; includes global cellular IPv6 (cellular IPv4 stays out of localIp / url as before).

Manual IPv6 check

On the phone, note an IPv6 from addresses (or from Settings). From another device that shares IPv6 reachability with the phone:

# Brackets are required for literal IPv6 hosts in URLs.
curl -v "http://[2001:db8::1]:18765/health"

The HTTP listener is dual-stack on both platforms (Android binds :: with IPv4 fallback; iOS GCDWebServer opens AF_INET + AF_INET6 sockets).

Handling errors

HttpServer.addListener('server-error', ({ message, fatal }) => {
  console.warn('Server error:', message, 'fatal:', fatal);
  if (fatal) {
    // On iOS the server dies in background; restart on foreground.
  }
});

Streaming a large file out

HttpServer.addListener('request', async (req) => {
  if (req.path === '/big.bin') {
    await HttpServer.respond({
      requestId: req.requestId,
      status: 200,
      headers: { 'content-type': 'application/octet-stream' },
      bodyFilePath: '/absolute/path/to/file.bin', // streamed, no RAM spike
    });
  } else {
    await HttpServer.respond({ requestId: req.requestId, status: 404 });
  }
});

Receiving a large upload

Bodies above fileBodyThresholdBytes (default 1 MB) are streamed to a temp file managed by the plugin. JavaScript receives the absolute path via bodyFilePath and is free to move, hash, or re-stream it. The plugin deletes the temp file automatically once respond() returns.

API

start(...)

start(options?: StartOptions | undefined) => any

Start the local HTTP server. Picks a free port if port is omitted. On Android, starts a foreground service so the server survives screen lock. Throws if the server is already running with a different configuration, or if required permissions are missing.

| Param | Type | | ------------- | ----------------------------------------------------- | | options | StartOptions |

Returns: any


stop()

stop() => any

Stop the server and release all resources (port, threads, temp files, service). Safe to call when already stopped.

Returns: any


respond(...)

respond(response: HttpResponse) => any

Reply to a request previously received via the request event. The plugin looks up requestId, writes the response, and cleans up any temporary body file. Calling respond twice for the same requestId is a no-op on the second call. Unanswered requests time out (504) after 60 s.

| Param | Type | | -------------- | ----------------------------------------------------- | | response | HttpResponse |

Returns: any


addListener('request', ...)

addListener(eventName: 'request', listener: (event: HttpRequestEvent) => void) => Promise<PluginListenerHandle> & PluginListenerHandle

Subscribe to incoming HTTP requests. Every accepted request fires exactly one request event; JS is expected to call respond exactly once per event.

| Param | Type | | --------------- | --------------------------------------------------------------------------------- | | eventName | 'request' | | listener | (event: HttpRequestEvent) => void |

Returns: any


addListener('server-error', ...)

addListener(eventName: 'server-error', listener: (event: ServerErrorEvent) => void) => Promise<PluginListenerHandle> & PluginListenerHandle

Subscribe to server-level errors that are not tied to a specific request (e.g. the underlying socket died, port was stolen, permission revoked). The server is considered dead when this fires with fatal: true.

| Param | Type | | --------------- | --------------------------------------------------------------------------------- | | eventName | 'server-error' | | listener | (event: ServerErrorEvent) => void |

Returns: any


removeAllListeners()

removeAllListeners() => any

Remove every listener registered on this plugin.

Returns: any


Interfaces

StartOptions

| Prop | Type | Description | | ---------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | port | number | If omitted or 0, the OS picks a free port in the dynamic range. | | maxBodyBytes | number | Max request body size in bytes. Bodies above this return 413. Default: 50 * 1024 * 1024 (50 MB). | | fileBodyThresholdBytes | number | Threshold above which the plugin stores the body in a temp file and exposes it via bodyFilePath instead of bodyBase64. Default: 1 * 1024 * 1024 (1 MB). | | android | StartOptionsAndroid | Android only. Title / text / small icon used by the foreground service notification (mandatory on Android 13+). Ignored on iOS. |

StartOptionsAndroid

| Prop | Type | Description | | --------------------------- | ------------------- | --------------------------------------------------------------- | | notificationTitle | string | Foreground-service notification title. Required on Android 13+. | | notificationText | string | Foreground-service notification body text. | | smallIconResourceName | string | Android drawable resource name, e.g. "ic_notification". | | channelId | string | Notification channel ID. Plugin creates the channel if missing. | | channelName | string | Notification channel display name. |

StartResult

| Prop | Type | Description | | --------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | port | number | Chosen port (same as options.port when provided). | | url | string | URL IPv4 LAN principale (comportement historique). Ex: "http://192.168.1.42:49281". Empty string when no LAN-reachable IPv4 interface was found (e.g. cellular only). The server is still listening on the port. Prefer urls when you need IPv6 or multiple candidates. A server-error event with fatal: false is emitted when both localIp and addresses are empty. | | localIp | string | Primary LAN IPv4 (comportement historique). Empty string when no LAN-reachable IPv4 interface was found. See url. | | addresses | Array<string> | All candidate IP addresses for reaching the server, without port or scheme. Includes LAN IPv4 plus routable IPv6 (global and ULA). Ex: ["192.168.1.42", "2001:db8::1"]. Excludes loopback and link-local (fe80::/10, 169.254.0.0/16). Sorted by reachability score (Wi‑Fi IPv4 first, then Ethernet, hotspot, then IPv6, with cellular IPv6 last). | | urls | Array<string> | Ready-to-use HTTP URLs for each entry in addresses. IPv6 hosts are bracketed: "http://[2001:db8::1]:18765". |

HttpResponse

| Prop | Type | Description | | ------------- | --------------------------------------------------- | ------------------------------------------------- | | data | any | Additional data received with the Http response. | | status | number | The status code received from the Http response. | | headers | HttpHeaders | The headers received from the Http response. | | url | string | The response URL received from the Http response. |

HttpHeaders

HttpRequestEvent

| Prop | Type | Description | | ------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | requestId | string | Opaque ID. Pass back unchanged in respond. | | method | HttpMethod | | | path | string | Decoded pathname, always starts with "/". No querystring. | | query | Record<string, string> | Parsed querystring. Repeated keys keep the last value. | | headers | Record<string, string> | Lower-cased header names. | | clientIp | string | Remote peer IP if available (useful for logging only). | | bodyText | string | Exactly one of the three body fields is set (undefined when the request has no body). The plugin picks the representation based on content type and size: - UTF-8 body with a text-like content type (text/*, application/json, application/x-www-form-urlencoded) below threshold -> bodyText - Any other body below threshold -> bodyBase64 - Body above threshold -> bodyFilePath Temp files referenced by bodyFilePath are deleted automatically once respond is called for the same requestId (or on timeout). | | bodyBase64 | string | | | bodyFilePath | string | |

PluginListenerHandle

| Prop | Type | | ------------ | ------------------------- | | remove | () => any |

ServerErrorEvent

| Prop | Type | Description | | ------------- | -------------------- | ------------------------------------------------------------------ | | message | string | | | fatal | boolean | True when the server is no longer listening and must be restarted. |

Type Aliases

HttpMethod

HTTP methods that the plugin forwards to JavaScript. Any value outside this list produced by a client is still forwarded as-is (upper-cased) but typed as HttpMethod for convenience of the common cases.

'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD'

Limitations

  • No HTTPS. The plugin listens on plain HTTP. Use it over a trusted LAN or add a reverse proxy if you need TLS.
  • No chunked transfer encoding on the request side. Clients must send Content-Length; otherwise the server replies with 501.
  • No WebSocket support (tracked separately; out of scope here).
  • iOS background. The server is suspended when the app leaves the foreground. A server-error event is emitted with fatal: true.
  • IPv4 NAT / CGNAT. Public IPv4 is almost never reachable end-to-end from another network; use LAN IPv4 or routable IPv6 instead.
  • Cellular firewalls. Some mobile carriers block inbound connections even when a global IPv6 is advertised — probe urls and expect failures.
  • Link-local IPv6 is never advertised (fe80::/10); peers would need the correct zone id and same L2 segment.

License

MIT © Cantoo Scribe