Skip to content

feat(powersync): add SyncOptions.headers for custom sync HTTP headers#420

Closed
xapple wants to merge 1 commit into
powersync-ja:mainfrom
xapple:feat/http-additional-headers
Closed

feat(powersync): add SyncOptions.headers for custom sync HTTP headers#420
xapple wants to merge 1 commit into
powersync-ja:mainfrom
xapple:feat/http-additional-headers

Conversation

@xapple
Copy link
Copy Markdown

@xapple xapple commented May 22, 2026

Adds SyncOptions.headers a Map<String, String>? of HTTP headers attached to every request the sync client makes to the PowerSync service.

await db.connect(
  connector: connector,
  options: SyncOptions(
    headers: {
      'CF-Access-Client-Id': '...',
      'CF-Access-Client-Secret': '...',
    },
  ),
);

Currently, the sync HTTP client is constructed internally with no header-injection hook (http.Client() and BrowserClient() are hardcoded). That blocks deployments behind corporate proxies, zero-trust gateways, or anything requiring per-request middleware (like Cloudflare Access).

The Supabase Flutter SDK already supports this via Supabase.initialize(headers: ...), but PowerSync has no equivalent.

A Client Function() factory would be more flexible (cert pinning, retry policy, interceptors) but closures cannot cross the web worker postMessage boundary.

No public API breaking changes, the field is optional and defaults to null. The DevConnector API calls in lib/src/connector.dart are out of scope.

I also let Claude add a couple tests to the PR. Tell me what you think. Thanks.

Copy link
Copy Markdown
Contributor

@simolus3 simolus3 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your contribution! I think this is a helpful feature to add. I wonder if these static headers set on SyncOptions are the best way to expose this, though. Alternatively, we could add an optional field on PowerSyncCredentials to provide custom headers. This would have the benefit of being able to generate fresh headers for each auth session (in case that's relevant). What do you think?

Internally, we might have to discuss whether this needs alignment with our other SDKs, but I'm leaning towards this being fine as-is. In Kotlin, Swift, and JS, the same thing is already possible through a custom http client or AbstractRemote implementation. We can't really let users inject their own http clients here since the sync process runs on a background isolate / worker, so adding this via a new option doesn't sound that risky to me.

external String? get syncParamsEncoded;
external UpdateSubscriptions? get subscriptions;
external String? get appMetadataEncoded;
external String? get headersEncoded;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW we might just want to use a plain JSObject for this, encoding key-value pair as JS fields using dart:js_interop_unsafe. But that shouldn't make a huge difference.

@xapple
Copy link
Copy Markdown
Author

xapple commented May 23, 2026

Yes, having a field on PowerSyncCredentials sounds good. It means the headers can change mid-session. I hadn't thought of that. And using a plain JSObject works for me too. Thanks for looking at the proposal! Let me know if you'd like me to draft some changes after your internal discussion.

@simolus3
Copy link
Copy Markdown
Contributor

simolus3 commented May 27, 2026

We've discussed this internally, and we have an update on how we probably want to expose this. Both SyncOptions and PowerSyncCredentials should be similar across our SDKs, and adding custom http headers as a field there is high-level enough that it's something we'd have to coordinate there.

Adding custom headers is essentially a network-level interceptor, and that's the way we expose that on our other SDKs. For example, our Kotlin SDK has this, which allows configuring the ktor client or providing a custom one.

Designing these APIs to be similar to what we have for Kotlin could be a good solution. What I'm thinking is essentially this:

  1. Add an optional Client Function() httpClientFactory field to SyncOptions (where Client is from package:http). Document that the function must be sendable across isolates.
  2. Send that to the sync isolate as part of _PowerSyncDatabaseIsolateArgs, and make the sync isolate use that instead of http.Client() in line 287.
  3. On the web, we'd have to do the same in line 78 of web_powersync_database.dart.
  4. To also apply this to workers, users would have to compile their own PowerSync worker applying this HTTP client. That requires some restructuring to expose options for worker entrypoints (worker.dart only exposes a main), but users could pass a custom http client here as well.

To add custom headers, you can write a client extending BaseClient that adds headers to requests before delegating to an inner client.

I think this gives users maximum flexibility, and could also be interesting if they want to use a custom (e.g. FFI based) http client with PowerSync.

@xapple
Copy link
Copy Markdown
Author

xapple commented May 27, 2026

I can see the factory approach is architecturally cleaner and cross-SDK idiomatic, but it's worse ergonomically for the common simple headers use case. In particular, web parity isn't really in scope as proposed: how much work would be left to the developer to get this working seamlessly? In my view, requiring devs to compile a custom worker for web clients is a significant build-step regression. Your call.

@T0b1i
Copy link
Copy Markdown

T0b1i commented May 29, 2026

We run PowerSync self-hosted behind TLS signed by our own private root CA (installed in the Android user trust store). On Android the sync connection fails with CERTIFICATE_VERIFY_FAILED, because Dart's dart:io/BoringSSL client only trusts the system store, not the user store. Setting SecurityContext.defaultContext in the main isolate doesn't help, since the sync client is built in the spawned isolate.

Two questions:

  1. Would the proposed Client Function() httpClientFactory solve this — i.e. could we return a client built with a custom SecurityContext carrying our CA?
  2. Or is there a plan to pass a trusted certificate (PEM/DER) directly via SyncOptions / PowerSyncCredentials?

@simolus3
Copy link
Copy Markdown
Contributor

In my view, requiring devs to compile a custom worker for web clients is a significant build-step regression.

That's true, but I feel a custom worker is also the cleanest solution to this. Of course we could "just add support for custom headers" since it's probably the most common request, but then we might get similar requests in the future. What if you need to attach a query parameter instead, or if you have a setup where one request header is essentially a signature of all the others (I've seen that happen!)? It's better to get the architecture right now, and then figure out what we can do to safely improve DX afterwards.

For instance, this page mentions that ReadableStream is a transferrable object we could move between the main tab and the worker. That should make it possible to implement an HTTP client in the main tab and have the worker use that without much of a performance penalty. We can make use of that eventually, but IMO making the HTTP client configurable through custom workers (and a nicer API for native platforms) is a good first step.

@simolus3
Copy link
Copy Markdown
Contributor

Would the proposed Client Function() httpClientFactory solve this — i.e. could we return a client built with a custom SecurityContext carrying our CA?

Yes, that's the idea 👍

Or is there a plan to pass a trusted certificate (PEM/DER) directly via SyncOptions / PowerSyncCredentials?

Now we're getting to these very reasonable requests which would completely blow up the complexity of our APIs :) This is why I think a custom HTTP client is the best API for this, it gives everyone maximum flexibility.

@simolus3
Copy link
Copy Markdown
Contributor

simolus3 commented Jun 1, 2026

@xapple, I'm also happy to take a look at this based on your initial work (I'll try some options to support an http client factory without custom workers too).

@xapple
Copy link
Copy Markdown
Author

xapple commented Jun 1, 2026

I suppose that having both solutions developed in parallel should not be an issue. A user could then go the quick&easy route for just a simple auth token in the headers, and use the more complex HTTP client factory when certificates are needed etc.

@simolus3
Copy link
Copy Markdown
Contributor

simolus3 commented Jun 2, 2026

A user could then go the quick&easy route for just a simple auth token in the headers

Given that we will have a general-purpose approach for this soon (#424), I don't think it makes sense to add headers as a separate API. We can't remove that functionality later, and we'd have to align that feature with all other SDKs which is a lot of effort for a use case also served by custom http clients.

Writing a custom client to add headers is not that hard:

final class AddHeadersClient extends BaseClient {
  final Client _inner;
  final Map<String, String> _additionalHeaders;

  AddHeadersClient(
      {Client? inner, required Map<String, String> additionalHeaders})
      : _inner = inner ?? Client(),
        _additionalHeaders = additionalHeaders;

  @override
  Future<StreamedResponse> send(BaseRequest request) async {
    _additionalHeaders.forEach((k, v) => request.headers[k] = v);
    return _inner.send(request);
  }

  @override
  void close() => _inner.close();
}

I don't think the ergonomics of this are bad enough to justify an API to make this particular usecase easier.

Anyway, thank you for your contribution and starting this discussion!


@xapple
Copy link
Copy Markdown
Author

xapple commented Jun 2, 2026

Sure, happy to contribute. I'm not sure I understand the full scope correctly, as I'm still learning, but it seems like this solution solves the "closure-can't-cross-postMessage problem" with a proxy architecture. So it would in fact work on the web client seamlessly (and no recompiling the worker). Well done!

@xapple xapple closed this Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants