feat: add custom protocol handler API#361
Open
0x77dev wants to merge 1 commit intoblackboardsh:mainfrom
Open
Conversation
4c493b6 to
6cf7358
Compare
9f95764 to
5fd6466
Compare
### 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
5fd6466 to
984ef6b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 theloadHTML()single-page injection, both of which have significant limitations.This is the missing primitive that Electron addresses with
protocol.handle()andsession.protocol.handle().Solution
Adds a first-class custom protocol API for Electrobun following the same two-phase design as Electron:
(request: Request) => Response | Promise<Response>using Web-standard Fetch semanticsThe handler model intentionally mirrors Bun's native
Bun.servefetch handler. Any scheme declared in config that matches a registeredProtocol.handle()callback receives fullRequest→Responsecontrol, including streaming response bodies, arbitrary status codes, custom headers, binary bodies, andAbortControllercancellation.Usage
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 buildorelectrobun devruns, the CLI readsruntime.protocolsfromelectrobun.config.tsand writes it intobuild.jsonalongside the existing renderer config. On startup, each platform's native wrapper readsbuild.jsonbefore callingCefInitializeor creating anyWKWebView/WebView2/WebKitWebView. Schemes are registered there -OnRegisterCustomSchemesin the CEF helper processes also receives them via a command-line switch propagated throughOnBeforeCommandLineProcessingandOnBeforeChildProcessLaunch. 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
JSCallbackregistered via FFI. That callback lands in the Bun event loop, constructs a Web-standardRequestobject, and calls the matchingProtocol.handle()function. The returnedResponse- including itsReadableStreambody if present - is streamed back chunk by chunk through a second set of FFI calls:protocolStartResponsedelivers status and headers, repeatedprotocolWriteResponseChunkcalls deliver body data as it becomes available, andprotocolFinishResponsesignals completion. Cancellation is handled viaAbortController: if the webview cancels a request mid-flight, the nativeCancel()method wakes any blocked FFI thread and the Bun-side stream reader is cancelled.On macOS, both the native
WKURLSchemeHandlerpath and the CEFCefResourceHandlerpath 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 untilprotocolFinishResponsefires. 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.tscover:GETtext response with custom response headersPUT,DELETE,OPTIONS,HEADmethods - verified method forwarding and HEAD body strippingx-custom-reqvisible in handlerheaders.get()combines valuesheaders.has(),headers.forEach(), spread/iteration of response headersresponse.ok,response.type,response.url,response.statusText,response.redirectedok: true), 201, 400, 500 (ok: false), 204 (no-body).text(),.json(),.arrayBuffer(),.bytes(),.blob(),.formData()response.clone()- independent reads of cloned bodybodyUsedtracking - second.text()call throwsTypeErrorresponse.body.getReader()- verifiesUint8ArraychunksURLSearchParamsrequest body andapplication/x-www-form-urlencodedresponse via.formData()AbortController.abort()before fetch - rejects withAbortErrorAbortController.abort()during body streaming - stream errors withAbortErrorAll 13 tests pass on macOS (both native WKWebView and CEF renderers). Windows and Linux passes require building on those platforms.
Known limitations
ReadableStreamresponses work at theProtocol.handle()API level but the browser receives all bytes at once. CEF is not affected.request.bodywill benull.electrobun.config.tsbefore app startup; adding schemes at runtime after webviews are created has no effect on the native renderer.Test plan
bun dev:cleaninpackage/- full native build passes on macOS (arm64)bun run typecheckinpackage/bun devinpackage/- kitchen app boots withelectrobun-test://scheme activenativeWrapper.cppcompiles with MSVC/clang-clnativeWrapper.cppcompiles with GCC/clang