# A Real Web Address for Plugin Pages

> How GoodBarber apps serve an app owner's own web pages from a web server inside the app, forward their calls to other websites, and restart the server after the background.

- Context: GoodBarber · Platform
- Published: 2026-06-24 · Updated: 2026-09-29
- Reading time: 6 min
- Canonical: https://lanfranchistephane.dev/articles/plugin-webviews/

## System at a glance

1. Plugin page — An app owner's own web page
2. Local web server — Serves its files inside the phone
3. Other websites — Called by the app for the page
4. Health check — Restarts the server if it stopped

## At a glance

- Role: iOS engineer who built the local web server behind plugin pages, the forwarding of their calls, and its recovery after the background.
- Challenge: Let app owners' web pages work like normal websites inside the app, without opening the phone's files or local network to anyone.
- Outcome: Plugin pages load from a trusted local address, can call other websites, and reload on their own when the server has to restart.

## What was at stake

### Plugin pages needed a real web address, without opening the phone to anyone.

GoodBarber builds thousands of iOS apps from one engine. An app owner can add their own web page to an app, written in HTML, CSS, and JavaScript: a custom code section, or a custom code widget on the home screen. This article calls these pages plugins. The app downloads their files and shows them in a web view, the browser that WebKit puts inside an app.

A browser judges a page by its origin, the start of the address it came from. The origin decides which web features the page may use, and other websites look at it before they answer. Plugins used to load from a private address the app invented. WebKit treated it as insecure: features such as WebCrypto were switched off, and many outside services refused the page's calls.

**My responsibility — the local server, its forwarding, and its recovery.** I built the server, the forwarding of web requests, and the recovery after the background, and moved every plugin screen onto them. A teammate wrote the first migration plan, and another fixed how text files declare their encoding.

## A web server inside the app

The engine now runs a small web server inside the app. It accepts connections only from the phone itself, on the address 127.0.0.1, so nothing outside the phone can reach it. WebKit counts that address as secure, like an https site, so a plugin page gets most of the features a normal website has.

When a screen opens a plugin, it registers the plugin's folder with the server and gets back an address for it. The server starts the first time a screen asks. It forgets a folder when the last screen using it closes.

<picture>
  <source media="(max-width: 720px)" srcset="https://lanfranchistephane.dev/articles/plugin-webviews/local-assets-flow-narrow.svg">
  <img src="https://lanfranchistephane.dev/articles/plugin-webviews/local-assets-flow.svg" width="960" height="350" loading="lazy" decoding="async" alt="A plugin page in the app's web view sends every request to a web server inside the app, at 127.0.0.1, which only this phone can reach. After checking this launch's secret, the server either sends a file from the plugin's registered folder, never one outside it, or calls another website for the page, never the phone itself or the local network.">
</picture>

Two details make the address hold:

- **The port stays the same from one launch to the next.** It is computed from the app's identifier, and a browser keeps cookies and saved data per origin, port included, so they survive a relaunch. If that port is taken, the system picks another.
- **Every address starts with a secret.** At each launch, the server draws a new random secret and puts it at the start of every path. Another app on the phone could find the port, but without the secret it is refused. The secret is not part of the origin, so a new one keeps saved data.

## Serving the plugin's files

Before a page loads, the app adds its own script and styles, makes sure the page has the library it uses to call native features such as sharing, and rewrites links to the plugin's files as plain relative names. It saves the result in the plugin's folder, and the web view loads it from the server.

```pseudo title="Serve one file"
if the path does not start with this launch's secret:
  refuse
find the folder registered for this plugin
if there is none:
  answer "not found"
resolve the file inside that folder, following ".." and shortcuts
if the result is outside the folder:
  refuse
send the file with its type
if a player asked for part of the file:
  send only those bytes
```

The type comes from the file's extension, and text is marked as UTF-8 only when it really is, so accented letters display correctly. Video players ask for one part of a file at a time; answering with only those bytes lets a local video start and seek.

## Calls to other websites

Plugin pages call outside services: a booking system, a form, a video playlist. A browser lets a page read another site's answer only if that site allows the page's origin, a rule called CORS. A service that only allows its own websites will never allow 127.0.0.1.

So the app adds a second small script to every plugin page, before the page's own code runs. It catches the page's calls to other websites, made with fetch or XMLHttpRequest, and sends them to the local server instead. The server makes the call as a native app, where CORS does not apply, and streams the answer back.

The real address travels in the path of the local request, so the relative addresses inside a fetched video playlist also resolve through the local server. That is what made streamed video (HLS) play.

On the way, the server:

- accepts only http and https, and refuses any address that names the phone itself or a private network, such as a home router. Redirects get the same check.
- refuses a request that the browser marks as sent from another website.
- removes the headers that would reveal the local address.
- sends the cookies the web view holds for that site, unless the page asked for none, and keeps the cookies the site sets in that store, out of the page's reach. A redirect to another site drops cookies and login headers.
- pauses the download when more than 4 MB are waiting for a slow page, resumes once less than 1 MB is left, and cancels it if the page stops listening.

## Coming back after the background

After a long stay in the background, iOS can close the server's connection while the system still reports the server as ready. Plugin pages then stayed blank. So the app no longer trusts the reported state: it asks the server itself.

```pseudo title="One check and one restart for every screen"
when the app becomes active, or a plugin page fails to load:
  if a check is already running:
    wait for its answer
  otherwise:
    call the server's private health address, with the secret
    if it answers within one second:
      the server is fine
    otherwise:
      replace the server with a new one
      wait until the new one answers the same call
      reload each plugin page that had loaded, once
```

WebKit does not always report it either when iOS closes a page's web process. So once the server is fine, each plugin screen also runs a quick script to check that its page is still there, and reloads it only if it is gone.

Limits stop a broken page from looping. A web view that loads from the local server retries once, a quarter of a second later, when it cannot connect. Each screen reloads at most three times in a row, and waits one second after a failed load. A successful load, or a new server, resets the count.

I tested the fix by killing the server by hand while the app still believed it was running. The check failed, a new server started, and the page came back without relaunching the app.

## When things do not go to plan

| What happens | What the app does |
|---|---|
| The server cannot start | A full-screen plugin shows an error and a widget stays empty; both retry when back on screen |
| An outside site cannot be reached | The page receives an error answer (502, bad gateway) |
| An outside site stops halfway through an answer | The page's connection is cut, so it sees an incomplete answer, never a complete one |
| A request is too large: headers over 16 KB, or a body over 10 MB | It is refused |

## Trade-offs

- **Some features need a real domain.** Apple Pay, passkeys, Google Sign-In, and logins that return to a registered address need a real, registered domain, which a local address cannot be. Cookies marked Secure are refused over plain http.
- **Only fetch and XMLHttpRequest calls are forwarded.** WebSocket and EventSource connections to other sites work only when those sites already accept the page.
- **A server inside the app can be closed by iOS in the background.** The health check and the bounded reloads are the price of a real origin.

## Related

- [One Layout Engine for the App's Content Lists](https://lanfranchistephane.dev/articles/layout-engine/) — GoodBarber · Platform, 8 min read. How GoodBarber's list templates got one shared layout: the same code sizes and places a list on the home page and on a full screen, measures each item with the view that draws it, and slots native ads between items.
- [Asking for Age Before Asking to Track](https://lanfranchistephane.dev/articles/privacy-feature-gates/) — GoodBarber · Platform, 7 min read. How GoodBarber apps ask for a person's age range before any tracking prompt, keep people under 16 away from ad networks, third-party analytics, and location, and check that answer again each time one of them runs.
