New:Microsoft Teams Notifications Are Now Available in Socket.Learn more
Get Started

@cantoo/capacitor-http-server

Package Overview
Dependencies
Maintainers
6
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@cantoo/capacitor-http-server

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

latest
Source
npmnpm
Version
1.0.8
Version published
Weekly downloads
37
-21.28%
Maintainers
6
Weekly downloads
 
Created
Source

@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.

ParamType
optionsStartOptions

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.

ParamType
responseHttpResponse

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.

ParamType
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.

ParamType
eventName'server-error'
listener(event: ServerErrorEvent) => void

Returns: any

removeAllListeners()

removeAllListeners() => any

Remove every listener registered on this plugin.

Returns: any

Interfaces

StartOptions

PropTypeDescription
portnumberIf omitted or 0, the OS picks a free port in the dynamic range.
maxBodyBytesnumberMax request body size in bytes. Bodies above this return 413. Default: 50 * 1024 * 1024 (50 MB).
fileBodyThresholdBytesnumberThreshold 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).
androidStartOptionsAndroidAndroid only. Title / text / small icon used by the foreground service notification (mandatory on Android 13+). Ignored on iOS.

StartOptionsAndroid

PropTypeDescription
notificationTitlestringForeground-service notification title. Required on Android 13+.
notificationTextstringForeground-service notification body text.
smallIconResourceNamestringAndroid drawable resource name, e.g. "ic_notification".
channelIdstringNotification channel ID. Plugin creates the channel if missing.
channelNamestringNotification channel display name.

StartResult

PropTypeDescription
portnumberChosen port (same as options.port when provided).
urlstringURL 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.
localIpstringPrimary LAN IPv4 (comportement historique). Empty string when no LAN-reachable IPv4 interface was found. See url.
addressesArray<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).
urlsArray<string>Ready-to-use HTTP URLs for each entry in addresses. IPv6 hosts are bracketed: "http://[2001:db8::1]:18765".

HttpResponse

PropTypeDescription
dataanyAdditional data received with the Http response.
statusnumberThe status code received from the Http response.
headersHttpHeadersThe headers received from the Http response.
urlstringThe response URL received from the Http response.

HttpHeaders

HttpRequestEvent

PropTypeDescription
requestIdstringOpaque ID. Pass back unchanged in respond.
methodHttpMethod
pathstringDecoded pathname, always starts with "/". No querystring.
queryRecord<string, string>Parsed querystring. Repeated keys keep the last value.
headersRecord<string, string>Lower-cased header names.
clientIpstringRemote peer IP if available (useful for logging only).
bodyTextstringExactly 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).
bodyBase64string
bodyFilePathstring

PluginListenerHandle

PropType
remove() => any

ServerErrorEvent

PropTypeDescription
messagestring
fatalbooleanTrue 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

Keywords

capacitor

FAQs

Package last updated on 12 Aug 2026

Related posts