Skip to content

feat: add custom protocol handler API#361

Open
0x77dev wants to merge 1 commit intoblackboardsh:mainfrom
0x77dev:feat/custom-protocol-handler
Open

feat: add custom protocol handler API#361
0x77dev wants to merge 1 commit intoblackboardsh:mainfrom
0x77dev:feat/custom-protocol-handler

Conversation

@0x77dev
Copy link
Copy Markdown

@0x77dev 0x77dev commented Apr 3, 2026

Problem

Electrobun had no way to serve custom content from a user-defined URL scheme. The only built-in content protocol was views://, which is hardcoded to serve bundled static assets. If an app needed to serve dynamic content without a localhost HTTP server - for example, to serve API responses or handle server-side rendered pages, assets from an in-process database, or streamed content from a local source - there was no supported path. The only options were a localhost sidecar server or the loadHTML() single-page injection, both of which have significant limitations.

This is the missing primitive that Electron addresses with protocol.handle() and session.protocol.handle().

Solution

Adds a first-class custom protocol API for Electrobun following the same two-phase design as Electron:

  1. Declare schemes in config - registered before any webview or CEF context initializes
  2. Handle requests at runtime - (request: Request) => Response | Promise<Response> using Web-standard Fetch semantics

The handler model intentionally mirrors Bun's native Bun.serve fetch handler. Any scheme declared in config that matches a registered Protocol.handle() callback receives full RequestResponse control, including streaming response bodies, arbitrary status codes, custom headers, binary bodies, and AbortController cancellation.

Usage

// electrobun.config.ts
export default {
  runtime: {
    protocols: [
      {
        scheme: "app",
        privileges: {
          standard: true,
          secure: true,
          corsEnabled: true,
          supportFetchAPI: true,
          stream: true,
        },
      },
    ],
  },
} satisfies ElectrobunConfig;
// src/bun/index.ts
import { Protocol } from "electrobun/bun";

Protocol.handle("app", async (request) => {
  const url = new URL(request.url);

  if (url.pathname === "/data") {
    const rows = await db.query("SELECT * FROM items");
    return Response.json(rows);
  }

  if (url.pathname === "/stream") {
    return new Response(
      new ReadableStream({
        async start(controller) {
          for await (const chunk of largeDataSource()) {
            controller.enqueue(chunk);
          }
          controller.close();
        },
      }),
      { headers: { "content-type": "application/octet-stream" } },
    );
  }

  return new Response("not found", { status: 404 });
});

The webview sees app://... URLs as a normal secure origin with full Fetch API support.

Architecture

The implementation follows the same two-phase split that Electron requires for registerSchemesAsPrivileged: schemes must be known before the browser engine initializes, but handlers are registered at runtime after the app starts.

Phase 1 - early declaration. When electrobun build or electrobun dev runs, the CLI reads runtime.protocols from electrobun.config.ts and writes it into build.json alongside the existing renderer config. On startup, each platform's native wrapper reads build.json before calling CefInitialize or creating any WKWebView/WebView2/WebKitWebView. Schemes are registered there - OnRegisterCustomSchemes in the CEF helper processes also receives them via a command-line switch propagated through OnBeforeCommandLineProcessing and OnBeforeChildProcessLaunch. This is the only moment registration is possible; any later call has no effect on the engine.

Phase 2 - runtime request handling. When a webview issues a fetch or navigation to a registered scheme, the native scheme handler fires on a background thread. It serializes the request - URL, method, headers, and body bytes - and invokes a Bun JSCallback registered via FFI. That callback lands in the Bun event loop, constructs a Web-standard Request object, and calls the matching Protocol.handle() function. The returned Response - including its ReadableStream body if present - is streamed back chunk by chunk through a second set of FFI calls: protocolStartResponse delivers status and headers, repeated protocolWriteResponseChunk calls deliver body data as it becomes available, and protocolFinishResponse signals completion. Cancellation is handled via AbortController: if the webview cancels a request mid-flight, the native Cancel() method wakes any blocked FFI thread and the Bun-side stream reader is cancelled.

On macOS, both the native WKURLSchemeHandler path and the CEF CefResourceHandler path deliver response chunks to the browser as they arrive. On Windows (WebResourceRequested + ICoreWebView2Deferral) and Linux (webkit_uri_scheme_request_finish_with_response), the native renderer path requires a complete response stream at delivery time, so chunks are buffered until protocolFinishResponse fires. The CEF path on all platforms is genuinely streaming in both cases.

Fetch spec coverage (kitchen test suite)

The 13 automated tests in kitchen/src/tests/protocol.test.ts cover:

  • Top-level navigation to a custom protocol page (native and CEF renderers)
  • GET text response with custom response headers
  • PUT, DELETE, OPTIONS, HEAD methods - verified method forwarding and HEAD body stripping
  • Custom request header forwarding - x-custom-req visible in handler
  • Multiple response headers with same name - headers.get() combines values
  • headers.has(), headers.forEach(), spread/iteration of response headers
  • response.ok, response.type, response.url, response.statusText, response.redirected
  • Status codes: 200 (ok: true), 201, 400, 500 (ok: false), 204 (no-body)
  • Body consumption: .text(), .json(), .arrayBuffer(), .bytes(), .blob(), .formData()
  • response.clone() - independent reads of cloned body
  • bodyUsed tracking - second .text() call throws TypeError
  • Streaming response consumed via response.body.getReader() - verifies Uint8Array chunks
  • URLSearchParams request body and application/x-www-form-urlencoded response via .formData()
  • Binary request and response round-trip - 256-byte 0x00–0xFF sequence preserved byte-for-byte
  • Large POST body (8 MiB) - complete round-trip through the protocol handler
  • AbortController.abort() before fetch - rejects with AbortError
  • AbortController.abort() during body streaming - stream errors with AbortError

All 13 tests pass on macOS (both native WKWebView and CEF renderers). Windows and Linux passes require building on those platforms.

Known limitations

  • Windows WebView2 and Linux WebKitGTK native paths buffer the full response body before delivery. Streaming ReadableStream responses work at the Protocol.handle() API level but the browser receives all bytes at once. CEF is not affected.
  • Linux WebKitGTK request body requires WebKitGTK ≥ 2.40.0. On older versions request.body will be null.
  • Custom schemes are not hot-reloadable. Schemes must be declared in electrobun.config.ts before app startup; adding schemes at runtime after webviews are created has no effect on the native renderer.

Test plan

  • bun dev:clean in package/ - full native build passes on macOS (arm64)
  • bun run typecheck in package/
  • bun dev in package/ - kitchen app boots with electrobun-test:// scheme active
  • All 13 Protocol category tests pass (Run All Automated in kitchen)
  • All existing automated tests continue to pass (no regressions in 101 pre-existing tests)
  • Windows native build - verify nativeWrapper.cpp compiles with MSVC/clang-cl
  • Linux native build - verify nativeWrapper.cpp compiles with GCC/clang

@0x77dev 0x77dev force-pushed the feat/custom-protocol-handler branch from 4c493b6 to 6cf7358 Compare April 3, 2026 02:50
@0x77dev 0x77dev marked this pull request as draft April 3, 2026 02:55
@0x77dev 0x77dev force-pushed the feat/custom-protocol-handler branch 3 times, most recently from 9f95764 to 5fd6466 Compare April 3, 2026 03:33
### Problem

Electrobun had no way to serve custom content from a user-defined URL scheme. The only built-in content protocol was `views://`, which is hardcoded to serve bundled static assets. If an app needed to serve dynamic content without a localhost HTTP server - for example, to serve API responses, assets from an in-process database, or streamed content from a local source - there was no supported path. The only options were a localhost sidecar server or the `loadHTML()` single-page injection, both of which have significant limitations.

This is the missing primitive that Electron addresses with `protocol.handle()` and `session.protocol.handle()`.

### Solution

Adds a first-class custom protocol API for Electrobun following the same two-phase design as Electron:

1. **Declare schemes in config** - registered before any webview or CEF context initializes
2. **Handle requests at runtime** - `(request: Request) => Response | Promise<Response>` using Web-standard Fetch semantics

The handler model intentionally mirrors Bun's native `Bun.serve` fetch handler. Any scheme declared in config that matches a registered `Protocol.handle()` callback receives full `Request` → `Response` control, including streaming response bodies, arbitrary status codes, custom headers, binary bodies, and `AbortController` cancellation.

### Usage

```typescript
// electrobun.config.ts
export default {
  runtime: {
    protocols: [
      {
        scheme: "app",
        privileges: {
          standard: true,
          secure: true,
          corsEnabled: true,
          supportFetchAPI: true,
          stream: true,
        },
      },
    ],
  },
} satisfies ElectrobunConfig;
```

```typescript
// src/bun/index.ts
import { Protocol } from "electrobun/bun";

Protocol.handle("app", async (request) => {
  const url = new URL(request.url);

  if (url.pathname === "/data") {
    const rows = await db.query("SELECT * FROM items");
    return Response.json(rows);
  }

  if (url.pathname === "/stream") {
    return new Response(
      new ReadableStream({
        async start(controller) {
          for await (const chunk of largeDataSource()) {
            controller.enqueue(chunk);
          }
          controller.close();
        },
      }),
      { headers: { "content-type": "application/octet-stream" } },
    );
  }

  return new Response("not found", { status: 404 });
});
```

The webview sees `app://...` URLs as a normal secure origin with full Fetch API support.

### Architecture

The implementation follows the same two-phase split that Electron requires for `registerSchemesAsPrivileged`: schemes must be known before the browser engine initializes, but handlers are registered at runtime after the app starts.

**Phase 1 - early declaration.** When `electrobun build` or `electrobun dev` runs, the CLI reads `runtime.protocols` from `electrobun.config.ts` and writes it into `build.json` alongside the existing renderer config. On startup, each platform's native wrapper reads `build.json` before calling `CefInitialize` or creating any `WKWebView`/`WebView2`/`WebKitWebView`. Schemes are registered there - `OnRegisterCustomSchemes` in the CEF helper processes also receives them via a command-line switch propagated through `OnBeforeCommandLineProcessing` and `OnBeforeChildProcessLaunch`. This is the only moment registration is possible; any later call has no effect on the engine.

**Phase 2 - runtime request handling.** When a webview issues a fetch or navigation to a registered scheme, the native scheme handler fires on a background thread. It serializes the request - URL, method, headers, and body bytes - and invokes a Bun `JSCallback` registered via FFI. That callback lands in the Bun event loop, constructs a Web-standard `Request` object, and calls the matching `Protocol.handle()` function. The returned `Response` - including its `ReadableStream` body if present - is streamed back chunk by chunk through a second set of FFI calls: `protocolStartResponse` delivers status and headers, repeated `protocolWriteResponseChunk` calls deliver body data as it becomes available, and `protocolFinishResponse` signals completion. Cancellation is handled via `AbortController`: if the webview cancels a request mid-flight, the native `Cancel()` method wakes any blocked FFI thread and the Bun-side stream reader is cancelled.

On macOS, both the native `WKURLSchemeHandler` path and the CEF `CefResourceHandler` path deliver response chunks to the browser as they arrive. On Windows (`WebResourceRequested` + `ICoreWebView2Deferral`) and Linux (`webkit_uri_scheme_request_finish_with_response`), the native renderer path requires a complete response stream at delivery time, so chunks are buffered until `protocolFinishResponse` fires. The CEF path on all platforms is genuinely streaming in both cases.

### Fetch spec coverage (kitchen test suite)

The 13 automated tests in `kitchen/src/tests/protocol.test.ts` cover:

- Top-level navigation to a custom protocol page (native and CEF renderers)
- `GET` text response with custom response headers
- `PUT`, `DELETE`, `OPTIONS`, `HEAD` methods - verified method forwarding and HEAD body stripping
- Custom request header forwarding - `x-custom-req` visible in handler
- Multiple response headers with same name - `headers.get()` combines values
- `headers.has()`, `headers.forEach()`, spread/iteration of response headers
- `response.ok`, `response.type`, `response.url`, `response.statusText`, `response.redirected`
- Status codes: 200 (`ok: true`), 201, 400, 500 (`ok: false`), 204 (no-body)
- Body consumption: `.text()`, `.json()`, `.arrayBuffer()`, `.bytes()`, `.blob()`, `.formData()`
- `response.clone()` - independent reads of cloned body
- `bodyUsed` tracking - second `.text()` call throws `TypeError`
- Streaming response consumed via `response.body.getReader()` - verifies `Uint8Array` chunks
- `URLSearchParams` request body and `application/x-www-form-urlencoded` response via `.formData()`
- Binary request and response round-trip - 256-byte 0x00–0xFF sequence preserved byte-for-byte
- Large POST body (8 MiB) - complete round-trip through the protocol handler
- `AbortController.abort()` before fetch - rejects with `AbortError`
- `AbortController.abort()` during body streaming - stream errors with `AbortError`

All 13 tests pass on macOS (both native WKWebView and CEF renderers). Windows and Linux passes require building on those platforms.

### Known limitations

- **Windows WebView2 and Linux WebKitGTK native paths buffer the full response body** before delivery. Streaming `ReadableStream` responses work at the `Protocol.handle()` API level but the browser receives all bytes at once. CEF is not affected.
- **Linux WebKitGTK request body** requires WebKitGTK ≥ 2.40.0. On older versions `request.body` will be `null`.
- **Custom schemes are not hot-reloadable.** Schemes must be declared in `electrobun.config.ts` before app startup; adding schemes at runtime after webviews are created has no effect on the native renderer.

### Test plan

- [x] `bun dev:clean` in `package/` - full native build passes on macOS (arm64)
- [x] `bun run typecheck` in `package/`
- [x] `bun dev` in `package/` - kitchen app boots with `electrobun-test://` scheme active
- [x] All 13 Protocol category tests pass (Run All Automated in kitchen)
- [x] All existing automated tests continue to pass (no regressions in 101 pre-existing tests)
- [ ] Windows native build - verify `nativeWrapper.cpp` compiles with MSVC/clang-cl
- [ ] Linux native build - verify `nativeWrapper.cpp` compiles with GCC/clang
@0x77dev 0x77dev force-pushed the feat/custom-protocol-handler branch from 5fd6466 to 984ef6b Compare April 3, 2026 04:36
@0x77dev 0x77dev marked this pull request as ready for review April 4, 2026 22:44
@YoavCodes YoavCodes added the idea label Apr 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants