Skip to content

Commit 8ad6eb2

Browse files
mikepitreclaude
andcommitted
feat(expo-native): add single-token client sync bridge
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5ef3c7d commit 8ad6eb2

13 files changed

Lines changed: 871 additions & 24 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/expo-native': minor
3+
---
4+
5+
Add native module support for the upcoming single-token client sync between `@clerk/expo` and the Clerk iOS and Android SDKs. The native SDK's stored device token becomes the one token both runtimes use, and each side is notified to reload its own client when the other changes it. Existing sync behavior is unchanged until `@clerk/expo` adopts it.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package expo.modules.clerk
2+
3+
import com.clerk.api.network.model.client.Client
4+
5+
/** The native client state that JS must refetch its own client for when it changes. */
6+
internal data class ClerkClientFingerprint(
7+
val clientId: String?,
8+
val lastActiveSessionId: String?,
9+
val sessions: List<SessionState>,
10+
val activeUserId: String?,
11+
val activeUserUpdatedAt: Long?,
12+
val deviceToken: String?
13+
) {
14+
data class SessionState(val id: String, val status: String)
15+
16+
companion object {
17+
fun from(client: Client?, deviceToken: String?): ClerkClientFingerprint {
18+
val activeUser = client?.sessions?.firstOrNull { it.id == client.lastActiveSessionId }?.user
19+
return ClerkClientFingerprint(
20+
clientId = client?.id,
21+
lastActiveSessionId = client?.lastActiveSessionId,
22+
sessions = client?.sessions?.map { SessionState(it.id, it.status.name) }.orEmpty(),
23+
activeUserId = activeUser?.id,
24+
activeUserUpdatedAt = activeUser?.updatedAt,
25+
deviceToken = deviceToken
26+
)
27+
}
28+
}
29+
}
30+
31+
/**
32+
* Emits one payload-free invalidation per main-loop turn in which the fingerprint moved away from
33+
* the last state JS was told about. Not thread-safe: call it from the main thread only.
34+
*/
35+
internal class ClerkClientInvalidationTracker(
36+
private val schedule: (() -> Unit) -> Unit,
37+
private val emit: () -> Unit
38+
) {
39+
private var baseline: ClerkClientFingerprint? = null
40+
private var latest: ClerkClientFingerprint? = null
41+
private var isFlushScheduled = false
42+
43+
/** Sets the state JS already knows about without emitting. */
44+
fun reset(fingerprint: ClerkClientFingerprint?) {
45+
baseline = fingerprint
46+
latest = fingerprint
47+
}
48+
49+
fun observe(fingerprint: ClerkClientFingerprint) {
50+
if (baseline == null) return
51+
latest = fingerprint
52+
if (fingerprint == baseline || isFlushScheduled) return
53+
isFlushScheduled = true
54+
schedule(::flush)
55+
}
56+
57+
/** A token JS wrote itself is not news to JS, so it must not echo back as an invalidation. */
58+
fun acknowledgeDeviceToken(token: String?, current: ClerkClientFingerprint) {
59+
baseline = baseline?.copy(deviceToken = token)
60+
observe(current)
61+
}
62+
63+
private fun flush() {
64+
isFlushScheduled = false
65+
val current = latest ?: return
66+
if (current == baseline) return
67+
baseline = current
68+
emit()
69+
}
70+
}
71+
72+
internal data class ClerkDeviceTokenBridgeError(val code: String, val message: String)
73+
74+
internal fun clerkSetDeviceTokenBridgeError(throwable: Throwable): ClerkDeviceTokenBridgeError = when (throwable) {
75+
is IllegalArgumentException -> ClerkDeviceTokenBridgeError(
76+
"E_INVALID_DEVICE_TOKEN",
77+
throwable.message ?: "Device token must not be blank"
78+
)
79+
is IllegalStateException -> ClerkDeviceTokenBridgeError(
80+
"E_NOT_CONFIGURED",
81+
throwable.message ?: "Clerk must be configured with configureNative before syncing client state."
82+
)
83+
else -> ClerkDeviceTokenBridgeError(
84+
"E_SET_DEVICE_TOKEN_FAILED",
85+
throwable.message ?: "Unable to set the device token"
86+
)
87+
}

‎packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt‎

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
package expo.modules.clerk
44

55
import android.content.Context
6+
import android.os.Handler
7+
import android.os.Looper
68
import android.util.Log
79
import androidx.compose.ui.graphics.Color
810
import androidx.compose.ui.unit.dp
@@ -37,12 +39,14 @@ import kotlinx.coroutines.flow.combine
3739
import kotlinx.coroutines.flow.distinctUntilChanged
3840
import kotlinx.coroutines.flow.first
3941
import kotlinx.coroutines.launch
42+
import kotlinx.coroutines.withContext
4043
import kotlinx.coroutines.withTimeout
4144
import org.json.JSONObject
4245

4346
private const val TAG = "ClerkExpoModule"
4447
private const val NATIVE_AUTH_FLOW_CHANGED_EVENT = "clerkNativeAuthFlowChanged"
4548
private const val NATIVE_CLIENT_CHANGED_EVENT = "clerkNativeClientChanged"
49+
private const val NATIVE_CLIENT_INVALIDATED_EVENT = "clerkNativeClientInvalidated"
4650
private const val HOST_SDK_HEADER = "x-clerk-host-sdk"
4751
private const val HOST_SDK_VERSION_HEADER = "x-clerk-host-sdk-version"
4852
private const val HOST_SDK = "expo"
@@ -204,6 +208,12 @@ class ClerkExpoModule : Module() {
204208
private var lastObservedClientState: ClientStateSnapshot? = null
205209
private var jsOriginatedClientSyncDepth = 0
206210
private var configuredPublishableKey: String? = null
211+
private val mainHandler = Handler(Looper.getMainLooper())
212+
private var clientInvalidationObserverJob: Job? = null
213+
private val clientInvalidationTracker = ClerkClientInvalidationTracker(
214+
schedule = { flush -> mainHandler.post(flush) },
215+
emit = { sendEvent(NATIVE_CLIENT_INVALIDATED_EVENT, emptyMap<String, Any?>()) }
216+
)
207217

208218
private data class AuthFlowStateSnapshot(
209219
val isLoaded: Boolean,
@@ -238,7 +248,7 @@ class ClerkExpoModule : Module() {
238248
override fun definition() = ModuleDefinition {
239249
Name("ClerkExpo")
240250

241-
Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT)
251+
Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT, NATIVE_CLIENT_INVALIDATED_EVENT)
242252

243253
OnCreate {
244254
sharedInstance = this@ClerkExpoModule
@@ -253,6 +263,9 @@ class ClerkExpoModule : Module() {
253263
authFlowStateObserverJob = null
254264
clientStateObserverJob?.cancel()
255265
clientStateObserverJob = null
266+
clientInvalidationObserverJob?.cancel()
267+
clientInvalidationObserverJob = null
268+
mainHandler.removeCallbacksAndMessages(null)
256269
}
257270

258271
AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise ->
@@ -263,6 +276,22 @@ class ClerkExpoModule : Module() {
263276
getClientToken(promise)
264277
}
265278

279+
AsyncFunction("configureNative") { pubKey: String, seedDeviceToken: String?, promise: Promise ->
280+
configureNative(pubKey, seedDeviceToken, promise)
281+
}
282+
283+
AsyncFunction("getDeviceToken") { promise: Promise ->
284+
getDeviceToken(promise)
285+
}
286+
287+
AsyncFunction("setDeviceToken") { token: String?, expected: String?, promise: Promise ->
288+
setDeviceToken(token, expected, promise)
289+
}
290+
291+
AsyncFunction("refreshClient") { promise: Promise ->
292+
refreshClient(promise)
293+
}
294+
266295
AsyncFunction("getAuthFlowState") { promise: Promise ->
267296
promise.resolve(authFlowStatePayload())
268297
}
@@ -615,6 +644,140 @@ class ClerkExpoModule : Module() {
615644
}
616645
}
617646

647+
// MARK: - single-token client sync
648+
649+
private fun configureNative(pubKey: String, seedDeviceToken: String?, promise: Promise) {
650+
val context = reactContext ?: run {
651+
promise.reject("E_CONFIGURE_FAILED", "React context is not available", null)
652+
return
653+
}
654+
655+
coroutineScope.launch {
656+
try {
657+
val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey
658+
val didConfigure = when {
659+
activePublishableKey == null -> {
660+
Clerk.initialize(context, pubKey, clerkConfigurationOptions())
661+
true
662+
}
663+
activePublishableKey != pubKey -> {
664+
Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions())
665+
true
666+
}
667+
else -> false
668+
}
669+
if (didConfigure) {
670+
configuredPublishableKey = pubKey
671+
appContext.currentActivity?.let { Clerk.attachActivity(it) }
672+
// Must follow initialize(), which resets customTheme.
673+
loadThemeFromAssets(context)
674+
}
675+
startClientStateObserver()
676+
677+
val didAdoptSeed = adoptSeedDeviceTokenIfNeeded(seedDeviceToken)
678+
startClientInvalidationObserver()
679+
lastObservedClientState = clientStateSnapshot()
680+
if (didAdoptSeed) {
681+
// An initialization refresh started before the seed was stored is fenced off by the token change.
682+
launch {
683+
val result = Clerk.refreshClient()
684+
if (result is ClerkResult.Failure) {
685+
debugLog(TAG, "configureNative - refresh after seed adoption failed: ${result.error}")
686+
}
687+
clientInvalidationTracker.observe(clientFingerprint())
688+
}
689+
}
690+
promise.resolve(null)
691+
} catch (e: Exception) {
692+
promise.reject("E_CONFIGURE_FAILED", "Failed to configure Clerk SDK: ${e.message}", e)
693+
}
694+
}
695+
}
696+
697+
private suspend fun adoptSeedDeviceTokenIfNeeded(seedDeviceToken: String?): Boolean {
698+
val seed = seedDeviceToken?.trim()?.takeIf { it.isNotEmpty() } ?: return false
699+
return withContext(Dispatchers.IO) {
700+
Clerk.getDeviceToken() == null && Clerk.setDeviceToken(seed, null)
701+
}
702+
}
703+
704+
private fun startClientInvalidationObserver() {
705+
clientInvalidationTracker.reset(clientFingerprint())
706+
if (clientInvalidationObserverJob != null) {
707+
return
708+
}
709+
710+
clientInvalidationObserverJob = coroutineScope.launch {
711+
Clerk.clientFlow.collect { client ->
712+
clientInvalidationTracker.observe(clientFingerprint(client))
713+
}
714+
}
715+
}
716+
717+
private fun clientFingerprint(client: Client? = Clerk.clientFlow.value): ClerkClientFingerprint {
718+
return ClerkClientFingerprint.from(client, clientStateSnapshot(client).deviceToken)
719+
}
720+
721+
private fun isClerkConfigured(): Boolean = Clerk.publishableKey != null
722+
723+
private fun getDeviceToken(promise: Promise) {
724+
if (!isClerkConfigured()) {
725+
promise.resolve(null)
726+
return
727+
}
728+
try {
729+
promise.resolve(Clerk.getDeviceToken())
730+
} catch (e: Exception) {
731+
promise.reject("E_GET_DEVICE_TOKEN_FAILED", e.message ?: "Unable to read the device token", e)
732+
}
733+
}
734+
735+
private fun setDeviceToken(token: String?, expected: String?, promise: Promise) {
736+
coroutineScope.launch {
737+
try {
738+
val didSet = withContext(Dispatchers.IO) { Clerk.setDeviceToken(token, expected) }
739+
val fingerprint = clientFingerprint()
740+
if (didSet) {
741+
clientInvalidationTracker.acknowledgeDeviceToken(fingerprint.deviceToken, fingerprint)
742+
} else {
743+
clientInvalidationTracker.observe(fingerprint)
744+
}
745+
promise.resolve(didSet)
746+
} catch (e: Exception) {
747+
val error = clerkSetDeviceTokenBridgeError(e)
748+
promise.reject(error.code, error.message, e)
749+
}
750+
}
751+
}
752+
753+
private fun refreshClient(promise: Promise) {
754+
if (!isClerkConfigured()) {
755+
promise.reject(
756+
"E_NOT_CONFIGURED",
757+
"Clerk must be configured with configureNative before syncing client state.",
758+
null
759+
)
760+
return
761+
}
762+
763+
coroutineScope.launch {
764+
try {
765+
val result = Clerk.refreshClient()
766+
clientInvalidationTracker.observe(clientFingerprint())
767+
when (result) {
768+
is ClerkResult.Success -> promise.resolve(null)
769+
is ClerkResult.Failure -> promise.reject(
770+
"E_REFRESH_CLIENT_FAILED",
771+
result.error?.firstMessage() ?: result.throwable?.message ?: "Client refresh failed",
772+
result.throwable
773+
)
774+
}
775+
} catch (e: Exception) {
776+
promise.reject("E_REFRESH_CLIENT_FAILED", e.message ?: "Client refresh failed", e)
777+
}
778+
}
779+
}
780+
618781
// MARK: - getClientToken
619782

620783
private fun getClientToken(promise: Promise) {

0 commit comments

Comments
 (0)