feat(sdk): add kotlin-platform-sdk (13 components) + 4 new TS client packages (32 tests)

This commit is contained in:
saravanakumardb1 2026-03-01 18:15:57 -08:00
parent 92a6929238
commit 91c48a7bc7
37 changed files with 2689 additions and 0 deletions

View File

@ -0,0 +1,21 @@
{
"name": "@bytelyst/feature-flag-client",
"version": "0.1.0",
"type": "module",
"description": "Browser/React Native-safe feature flag client for platform-service",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run"
}
}

View File

@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createFeatureFlagClient } from './client.js';
describe('createFeatureFlagClient', () => {
const baseConfig = {
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
platform: 'web',
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should create a client with isEnabled returning false before init', () => {
const client = createFeatureFlagClient(baseConfig);
expect(client.isEnabled('any_flag')).toBe(false);
});
it('should fetch flags on init', async () => {
const mockFlags = { premium: true, beta_feature: false };
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ flags: mockFlags }),
})
);
const client = createFeatureFlagClient(baseConfig);
await client.init();
expect(client.isEnabled('premium')).toBe(true);
expect(client.isEnabled('beta_feature')).toBe(false);
expect(client.isEnabled('nonexistent')).toBe(false);
client.stop();
});
it('should return all flags via getAllFlags', async () => {
const mockFlags = { a: true, b: false };
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ flags: mockFlags }),
})
);
const client = createFeatureFlagClient(baseConfig);
await client.init();
expect(client.getAllFlags()).toEqual({ a: true, b: false });
client.stop();
});
it('should send correct headers', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ flags: {} }),
});
vi.stubGlobal('fetch', fetchMock);
const client = createFeatureFlagClient(baseConfig);
await client.init({ userId: 'user-123' });
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/flags/poll?'),
expect.objectContaining({
headers: { 'x-product-id': 'testapp' },
})
);
const url = fetchMock.mock.calls[0][0] as string;
expect(url).toContain('platform=web');
expect(url).toContain('userId=user-123');
client.stop();
});
it('should keep existing flags on network error', async () => {
let callCount = 0;
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ flags: { initial: true } }),
});
}
return Promise.reject(new Error('network'));
})
);
const client = createFeatureFlagClient(baseConfig);
await client.init();
expect(client.isEnabled('initial')).toBe(true);
await client.refresh();
expect(client.isEnabled('initial')).toBe(true);
client.stop();
});
it('should persist to storage when provided', async () => {
const store: Record<string, string> = {};
const storage = {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => {
store[k] = v;
},
};
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ flags: { cached: true } }),
})
);
const client = createFeatureFlagClient({ ...baseConfig, storage });
await client.init();
expect(store['testapp-feature-flags']).toBeDefined();
expect(JSON.parse(store['testapp-feature-flags'])).toEqual({ cached: true });
client.stop();
});
it('should restore flags from storage on creation', () => {
const store: Record<string, string> = {
'testapp-feature-flags': JSON.stringify({ restored: true }),
};
const storage = {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => {
store[k] = v;
},
};
const client = createFeatureFlagClient({ ...baseConfig, storage });
expect(client.isEnabled('restored')).toBe(true);
});
it('should stop polling on stop()', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ flags: {} }),
})
);
const client = createFeatureFlagClient({ ...baseConfig, pollIntervalMs: 1000 });
await client.init();
client.stop();
expect(client.isEnabled('anything')).toBe(false);
expect(client.getAllFlags()).toEqual({});
});
});

View File

@ -0,0 +1,110 @@
/**
* Browser/React Native-safe feature flag client for platform-service.
*
* Polls GET /api/flags/poll on a configurable interval and caches results.
* No Node.js dependencies uses globalThis.fetch.
*
* @example
* ```ts
* import { createFeatureFlagClient } from '@bytelyst/feature-flag-client';
*
* const flags = createFeatureFlagClient({
* baseUrl: 'http://localhost:4003/api',
* productId: 'nomgap',
* platform: 'mobile',
* });
*
* await flags.init({ userId: 'user-123' });
* if (flags.isEnabled('premium_body_viz')) { ... }
* ```
*/
import type { FeatureFlagClient, FeatureFlagClientConfig } from './types.js';
export function createFeatureFlagClient(config: FeatureFlagClientConfig): FeatureFlagClient {
const {
baseUrl,
productId,
platform,
pollIntervalMs = 5 * 60 * 1000,
storage,
storagePrefix,
} = config;
const prefix = storagePrefix ?? productId;
const STORAGE_KEY = `${prefix}-feature-flags`;
let flags: Record<string, boolean> = {};
let initialized = false;
let intervalId: ReturnType<typeof setInterval> | null = null;
let userId: string | undefined;
// Restore from storage on creation
if (storage) {
try {
const cached = storage.getItem(STORAGE_KEY);
if (cached) flags = JSON.parse(cached);
} catch {
// Ignore parse errors
}
}
async function fetchFlags(): Promise<void> {
try {
const parts = [`platform=${encodeURIComponent(platform)}`];
if (userId) parts.push(`userId=${encodeURIComponent(userId)}`);
const res = await globalThis.fetch(`${baseUrl}/flags/poll?${parts.join('&')}`, {
headers: { 'x-product-id': productId },
});
if (!res.ok) return;
const data = (await res.json()) as { flags?: Record<string, boolean> };
flags = data.flags ?? {};
// Persist to storage
if (storage) {
try {
storage.setItem(STORAGE_KEY, JSON.stringify(flags));
} catch {
// Storage write failure — non-fatal
}
}
} catch {
// Keep existing flags on network error
}
}
async function init(params?: { userId?: string }): Promise<void> {
if (initialized) return;
initialized = true;
userId = params?.userId;
await fetchFlags();
intervalId = setInterval(() => {
void fetchFlags();
}, pollIntervalMs);
}
function isEnabled(key: string): boolean {
return flags[key] === true;
}
function getAllFlags(): Readonly<Record<string, boolean>> {
return flags;
}
async function refresh(): Promise<void> {
await fetchFlags();
}
function stop(): void {
if (intervalId) clearInterval(intervalId);
intervalId = null;
flags = {};
initialized = false;
userId = undefined;
}
return { init, isEnabled, getAllFlags, refresh, stop };
}

View File

@ -0,0 +1,2 @@
export { createFeatureFlagClient } from './client.js';
export type { FeatureFlagClient, FeatureFlagClientConfig } from './types.js';

View File

@ -0,0 +1,44 @@
/**
* Types for @bytelyst/feature-flag-client.
* Browser/React Native-safe no Node.js dependencies.
*/
export interface FeatureFlagClientConfig {
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
baseUrl: string;
/** Product identifier sent as x-product-id header. */
productId: string;
/** Platform string for the poll query (e.g. "mobile", "web"). */
platform: string;
/** Poll interval in milliseconds. Default: 5 minutes. */
pollIntervalMs?: number;
/** Optional persistent storage adapter for flag cache. */
storage?: {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
};
/** Optional storage key prefix. Default: productId. */
storagePrefix?: string;
}
export interface FeatureFlagClient {
/** Initialize the client: fetch flags immediately and start polling. */
init(params?: { userId?: string }): Promise<void>;
/** Check if a feature flag is enabled. Returns false if not found. */
isEnabled(key: string): boolean;
/** Get all currently cached flags. */
getAllFlags(): Readonly<Record<string, boolean>>;
/** Force a refresh of feature flags. */
refresh(): Promise<void>;
/** Stop polling and reset state. */
stop(): void;
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,21 @@
{
"name": "@bytelyst/kill-switch-client",
"version": "0.1.0",
"type": "module",
"description": "Browser/React Native-safe kill switch client for platform-service",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run"
}
}

View File

@ -0,0 +1,102 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createKillSwitchClient } from './index.js';
describe('createKillSwitchClient', () => {
const baseConfig = {
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
};
afterEach(() => {
vi.restoreAllMocks();
});
it('should return disabled=false when app is not disabled', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ disabled: false, message: null }),
})
);
const ks = createKillSwitchClient(baseConfig);
const result = await ks.check();
expect(result.disabled).toBe(false);
expect(result.message).toBeNull();
});
it('should return disabled=true with message when app is disabled', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ disabled: true, message: 'Maintenance in progress' }),
})
);
const ks = createKillSwitchClient(baseConfig);
const result = await ks.check();
expect(result.disabled).toBe(true);
expect(result.message).toBe('Maintenance in progress');
});
it('should fail-open on network error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')));
const ks = createKillSwitchClient(baseConfig);
const result = await ks.check();
expect(result.disabled).toBe(false);
expect(result.message).toBeNull();
});
it('should fail-open on non-OK response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
})
);
const ks = createKillSwitchClient(baseConfig);
const result = await ks.check();
expect(result.disabled).toBe(false);
});
it('should send correct product-id header', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ disabled: false }),
});
vi.stubGlobal('fetch', fetchMock);
const ks = createKillSwitchClient(baseConfig);
await ks.check();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/flags/kill-switch'),
expect.objectContaining({
headers: { 'x-product-id': 'testapp' },
})
);
});
it('should include platform in query string', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ disabled: false }),
});
vi.stubGlobal('fetch', fetchMock);
const ks = createKillSwitchClient({ ...baseConfig, platform: 'ios' });
await ks.check();
const url = fetchMock.mock.calls[0][0] as string;
expect(url).toContain('platform=ios');
});
});

View File

@ -0,0 +1,68 @@
/**
* Browser/React Native-safe kill switch client for platform-service.
*
* Checks GET /api/flags/kill-switch to determine if the app is disabled.
* Fail-open: returns { disabled: false } on any network error.
*
* @example
* ```ts
* import { createKillSwitchClient } from '@bytelyst/kill-switch-client';
*
* const ks = createKillSwitchClient({
* baseUrl: 'http://localhost:4003/api',
* productId: 'nomgap',
* });
*
* const result = await ks.check();
* if (result.disabled) showBlockScreen(result.message);
* ```
*/
export interface KillSwitchClientConfig {
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
baseUrl: string;
/** Product identifier sent as x-product-id header. */
productId: string;
/** Platform string for the query (e.g. "mobile", "web"). Default: "mobile". */
platform?: string;
}
export interface KillSwitchResult {
disabled: boolean;
message: string | null;
}
export interface KillSwitchClient {
/** Check if the app is disabled. Fail-open on any error. */
check(): Promise<KillSwitchResult>;
}
export function createKillSwitchClient(config: KillSwitchClientConfig): KillSwitchClient {
const { baseUrl, productId, platform = 'mobile' } = config;
async function check(): Promise<KillSwitchResult> {
try {
const res = await globalThis.fetch(
`${baseUrl}/flags/kill-switch?platform=${encodeURIComponent(platform)}`,
{
headers: { 'x-product-id': productId },
}
);
if (!res.ok) return { disabled: false, message: null };
const data = (await res.json()) as KillSwitchResult;
return {
disabled: data.disabled ?? false,
message: data.message ?? null,
};
} catch {
// Fail-open: network errors should NOT block the user
return { disabled: false, message: null };
}
}
return { check };
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,57 @@
plugins {
id("com.android.library") version "8.7.3"
id("org.jetbrains.kotlin.android") version "2.1.0"
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0"
}
android {
namespace = "com.bytelyst.platform"
compileSdk = 35
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
group = "com.bytelyst.platform"
version = "0.1.0"
dependencies {
// HTTP
api("com.squareup.okhttp3:okhttp:4.12.0")
// Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// Android
implementation("androidx.security:security-crypto:1.0.0")
implementation("androidx.biometric:biometric:1.1.0")
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
// Testing
testImplementation("org.junit.jupiter:junit-jupiter-api:5.11.4")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.11.4")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("org.robolectric:robolectric:4.14.1")
}
tasks.withType<Test> {
useJUnitPlatform()
}

View File

@ -0,0 +1,3 @@
# ByteLyst Platform SDK consumer ProGuard rules
# Keep all SDK public API classes
-keep class com.bytelyst.platform.** { *; }

View File

@ -0,0 +1 @@
android.useAndroidX=true

View File

@ -0,0 +1 @@
rootProject.name = "kotlin-platform-sdk"

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- No permissions needed at the library level.
Consumer apps declare their own permissions. -->
</manifest>

View File

@ -0,0 +1,111 @@
package com.bytelyst.platform
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Local rotating JSON audit log.
*
* Writes audit entries to a JSON lines file in the app's files directory.
* Rotates when the file exceeds [maxFileSizeBytes]. Keeps [maxFiles] rotated files.
*
* Mirrors the Swift BLAuditLogger API.
*/
class BLAuditLogger(
context: Context,
private val config: BLPlatformConfig,
private val maxFileSizeBytes: Long = 1_000_000L,
private val maxFiles: Int = 5,
) {
@Serializable
data class AuditEntry(
val timestamp: String,
val productId: String,
val action: String,
val module: String,
val detail: String? = null,
val userId: String? = null,
)
private val json = Json { encodeDefaults = true }
private val logDir = File(context.filesDir, "audit_logs")
private val currentFile: File
get() = File(logDir, "${config.productId}_audit.jsonl")
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
init {
logDir.mkdirs()
}
/**
* Log an audit event.
*/
fun log(action: String, module: String, detail: String? = null, userId: String? = null) {
val entry = AuditEntry(
timestamp = isoFormat.format(Date()),
productId = config.productId,
action = action,
module = module,
detail = detail,
userId = userId,
)
try {
rotateIfNeeded()
currentFile.appendText(json.encodeToString(entry) + "\n")
} catch (_: Exception) {
// Audit logging should never crash the app
}
}
/**
* Read all entries from the current log file.
*/
fun readEntries(): List<AuditEntry> {
return try {
if (!currentFile.exists()) return emptyList()
currentFile.readLines()
.filter { it.isNotBlank() }
.mapNotNull {
try { json.decodeFromString<AuditEntry>(it) } catch (_: Exception) { null }
}
} catch (_: Exception) {
emptyList()
}
}
/**
* Clear all audit log files.
*/
fun clear() {
try {
logDir.listFiles()?.forEach { it.delete() }
} catch (_: Exception) {
// Ignore
}
}
private fun rotateIfNeeded() {
if (!currentFile.exists() || currentFile.length() < maxFileSizeBytes) return
// Rotate: current → .1, .1 → .2, etc.
for (i in maxFiles downTo 1) {
val from = if (i == 1) currentFile else File(logDir, "${config.productId}_audit.${i - 1}.jsonl")
val to = File(logDir, "${config.productId}_audit.$i.jsonl")
if (from.exists()) {
to.delete()
from.renameTo(to)
}
}
}
}

View File

@ -0,0 +1,304 @@
package com.bytelyst.platform
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Auth client for platform-service.
*
* Manages login, register, token refresh, password operations.
* Tokens are stored in [BLSecureStore] (EncryptedSharedPreferences).
* Auth state is exposed as a [StateFlow] for reactive UI binding.
*
* Mirrors the Swift BLAuthClient API.
*/
class BLAuthClient(
private val config: BLPlatformConfig,
private val secureStore: BLSecureStore,
) {
// ── Data classes ──────────────────────────────────────────
@Serializable
data class AuthUser(
val id: String = "",
val email: String = "",
@SerialName("displayName")
val name: String = "",
val plan: String = "free",
val role: String = "user",
)
@Serializable
data class TokenResponse(
val accessToken: String,
val refreshToken: String,
val user: AuthUser,
)
@Serializable
data class RefreshResponse(
val accessToken: String,
val refreshToken: String,
)
@Serializable
data class MessageResponse(val message: String)
sealed class AuthState {
data object Loading : AuthState()
data object LoggedOut : AuthState()
data class LoggedIn(val user: AuthUser) : AuthState()
data class Error(val message: String) : AuthState()
}
// ── State ────────────────────────────────────────────────
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
val state: StateFlow<AuthState> = _state.asStateFlow()
val isLoggedIn: Boolean
get() = _state.value is AuthState.LoggedIn
val currentUser: AuthUser?
get() = (_state.value as? AuthState.LoggedIn)?.user
// ── Storage keys (bare — applicationId provides namespace) ──
companion object {
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_USER_EMAIL = "user_email"
private const val KEY_USER_NAME = "user_name"
private const val KEY_USER_PLAN = "user_plan"
private const val KEY_USER_ID = "user_id"
}
// ── Platform client ──────────────────────────────────────
private val client = BLPlatformClient(config) { getAccessToken() }
// ── Token management ─────────────────────────────────────
fun getAccessToken(): String? = secureStore.read(KEY_ACCESS_TOKEN)
fun getRefreshToken(): String? = secureStore.read(KEY_REFRESH_TOKEN)
private fun setTokens(accessToken: String, refreshToken: String) {
secureStore.save(KEY_ACCESS_TOKEN, accessToken)
secureStore.save(KEY_REFRESH_TOKEN, refreshToken)
}
private fun saveUser(user: AuthUser) {
secureStore.save(KEY_USER_ID, user.id)
secureStore.save(KEY_USER_EMAIL, user.email)
secureStore.save(KEY_USER_NAME, user.name)
secureStore.save(KEY_USER_PLAN, user.plan)
}
private fun clearAll() {
secureStore.delete(KEY_ACCESS_TOKEN)
secureStore.delete(KEY_REFRESH_TOKEN)
secureStore.delete(KEY_USER_EMAIL)
secureStore.delete(KEY_USER_NAME)
secureStore.delete(KEY_USER_PLAN)
secureStore.delete(KEY_USER_ID)
}
// ── Session check ────────────────────────────────────────
/**
* Check for an existing session from stored tokens.
* Call once at app startup to restore auth state.
*/
fun checkExistingSession() {
val token = secureStore.read(KEY_ACCESS_TOKEN)
val email = secureStore.read(KEY_USER_EMAIL)
if (!token.isNullOrBlank() && !email.isNullOrBlank()) {
val user = AuthUser(
id = secureStore.read(KEY_USER_ID) ?: "",
email = email,
name = secureStore.read(KEY_USER_NAME) ?: "",
plan = secureStore.read(KEY_USER_PLAN) ?: "free",
)
_state.value = AuthState.LoggedIn(user)
} else {
_state.value = AuthState.LoggedOut
}
}
// ── Auth operations ──────────────────────────────────────
suspend fun login(email: String, password: String) {
_state.value = AuthState.Loading
try {
val body = client.json.encodeToString(
kotlinx.serialization.builtins.MapSerializer(
kotlinx.serialization.builtins.serializer<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
mapOf("email" to email, "password" to password, "productId" to config.productId),
)
val response = client.request("POST", "/auth/login", body, skipAuth = true)
val result = client.json.decodeFromString<TokenResponse>(response)
handleAuthResult(result)
} catch (e: Exception) {
_state.value = AuthState.Error(e.message ?: "Login failed")
}
}
suspend fun register(name: String, email: String, password: String) {
_state.value = AuthState.Loading
try {
val body = client.json.encodeToString(
kotlinx.serialization.builtins.MapSerializer(
kotlinx.serialization.builtins.serializer<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
mapOf(
"email" to email,
"displayName" to name,
"password" to password,
"productId" to config.productId,
),
)
val response = client.request("POST", "/auth/register", body, skipAuth = true)
val result = client.json.decodeFromString<TokenResponse>(response)
handleAuthResult(result)
} catch (e: Exception) {
_state.value = AuthState.Error(e.message ?: "Registration failed")
}
}
fun logout() {
clearAll()
_state.value = AuthState.LoggedOut
}
// ── Token refresh (singleton guard) ──────────────────────
private val refreshMutex = Mutex()
suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock {
val rt = getRefreshToken() ?: return false
return try {
val body = client.json.encodeToString(
kotlinx.serialization.builtins.MapSerializer(
kotlinx.serialization.builtins.serializer<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
mapOf("refreshToken" to rt),
)
val response = client.request("POST", "/auth/refresh", body, skipAuth = true)
val result = client.json.decodeFromString<RefreshResponse>(response)
setTokens(result.accessToken, result.refreshToken)
true
} catch (e: BLApiException) {
if (e.statusCode == 401) {
withContext(Dispatchers.Main) { logout() }
}
false
} catch (_: Exception) {
false
}
}
// ── Password management ──────────────────────────────────
suspend fun forgotPassword(email: String): String {
val body = client.json.encodeToString(
kotlinx.serialization.builtins.MapSerializer(
kotlinx.serialization.builtins.serializer<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
mapOf("email" to email, "productId" to config.productId),
)
val response = client.request("POST", "/auth/forgot-password", body, skipAuth = true)
return client.json.decodeFromString<MessageResponse>(response).message
}
suspend fun resetPassword(token: String, newPassword: String): String {
val body = client.json.encodeToString(
kotlinx.serialization.builtins.MapSerializer(
kotlinx.serialization.builtins.serializer<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
mapOf("token" to token, "newPassword" to newPassword),
)
val response = client.request("POST", "/auth/reset-password", body, skipAuth = true)
return client.json.decodeFromString<MessageResponse>(response).message
}
suspend fun changePassword(currentPassword: String, newPassword: String): String {
val body = client.json.encodeToString(
kotlinx.serialization.builtins.MapSerializer(
kotlinx.serialization.builtins.serializer<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
mapOf("currentPassword" to currentPassword, "newPassword" to newPassword),
)
val response = client.request("POST", "/auth/change-password", body)
return client.json.decodeFromString<MessageResponse>(response).message
}
// ── Email verification ───────────────────────────────────
suspend fun verifyEmail(token: String): String {
val body = client.json.encodeToString(
kotlinx.serialization.builtins.MapSerializer(
kotlinx.serialization.builtins.serializer<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
mapOf("token" to token),
)
val response = client.request("POST", "/auth/verify-email", body)
return client.json.decodeFromString<MessageResponse>(response).message
}
suspend fun resendVerification(email: String): String {
val body = client.json.encodeToString(
kotlinx.serialization.builtins.MapSerializer(
kotlinx.serialization.builtins.serializer<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
mapOf("email" to email, "productId" to config.productId),
)
val response = client.request("POST", "/auth/resend-verification", body)
return client.json.decodeFromString<MessageResponse>(response).message
}
// ── Account management ───────────────────────────────────
suspend fun deleteAccount(password: String): String {
val body = client.json.encodeToString(
kotlinx.serialization.builtins.MapSerializer(
kotlinx.serialization.builtins.serializer<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
mapOf("password" to password),
)
val response = client.request("DELETE", "/auth/account", body)
clearAll()
_state.value = AuthState.LoggedOut
return client.json.decodeFromString<MessageResponse>(response).message
}
suspend fun getMe(): AuthUser {
val response = client.request("GET", "/auth/me")
return client.json.decodeFromString<AuthUser>(response)
}
// ── Private ──────────────────────────────────────────────
private fun handleAuthResult(result: TokenResponse) {
setTokens(result.accessToken, result.refreshToken)
saveUser(result.user)
_state.value = AuthState.LoggedIn(result.user)
}
}

View File

@ -0,0 +1,83 @@
package com.bytelyst.platform
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Biometric authentication wrapper (Face / Fingerprint).
*
* Uses AndroidX BiometricPrompt for hardware-backed authentication.
* Mirrors the Swift BLBiometricAuth API.
*/
object BLBiometricAuth {
enum class BiometricResult {
SUCCESS,
CANCELLED,
NOT_AVAILABLE,
ERROR,
}
/**
* Check if biometric authentication is available on this device.
*/
fun isAvailable(activity: FragmentActivity): Boolean {
val manager = BiometricManager.from(activity)
return manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Show a biometric prompt and return the result.
*
* Must be called from a FragmentActivity (Compose activities extend this).
*/
suspend fun authenticate(
activity: FragmentActivity,
title: String = "Authenticate",
subtitle: String? = null,
negativeButtonText: String = "Cancel",
): BiometricResult {
if (!isAvailable(activity)) return BiometricResult.NOT_AVAILABLE
return suspendCoroutine { continuation ->
val executor = ContextCompat.getMainExecutor(activity)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
continuation.resume(BiometricResult.SUCCESS)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
val result = if (
errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
errorCode == BiometricPrompt.ERROR_CANCELED
) {
BiometricResult.CANCELLED
} else {
BiometricResult.ERROR
}
continuation.resume(result)
}
override fun onAuthenticationFailed() {
// Individual attempt failed, prompt stays open — don't resume yet
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.apply { subtitle?.let { setSubtitle(it) } }
.setNegativeButtonText(negativeButtonText)
.build()
prompt.authenticate(info)
}
}
}

View File

@ -0,0 +1,93 @@
package com.bytelyst.platform
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Azure Blob Storage client via platform-service SAS tokens.
*
* Flow: Get SAS token from POST /api/blob/sas Upload directly to Azure Blob.
* Mirrors the Swift BLBlobClient API.
*/
class BLBlobClient(
private val config: BLPlatformConfig,
private val tokenProvider: () -> String? = { null },
) {
@Serializable
data class SasResponse(
val sasUrl: String,
val blobUrl: String,
)
private val json = Json { ignoreUnknownKeys = true }
private val platformClient = BLPlatformClient(config, tokenProvider)
private val uploadClient = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Upload data to Azure Blob Storage.
*
* @param data The raw bytes to upload.
* @param container Azure Blob container name (e.g., "audio", "attachments").
* @param fileName The blob name / file path.
* @param contentType MIME type (e.g., "audio/wav", "image/png").
* @return The public blob URL on success, or null on failure.
*/
suspend fun upload(
data: ByteArray,
container: String,
fileName: String,
contentType: String,
): String? = withContext(Dispatchers.IO) {
try {
// Step 1: Get SAS token from platform-service
val sasBody = json.encodeToString(
kotlinx.serialization.builtins.MapSerializer(
kotlinx.serialization.builtins.serializer<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
mapOf(
"container" to container,
"blobName" to fileName,
"permissions" to "w",
"contentType" to contentType,
),
)
val sasResponse = platformClient.request("POST", "/blob/sas", sasBody)
val sas = json.decodeFromString<SasResponse>(sasResponse)
// Step 2: Upload directly to Azure Blob via SAS URL
val request = Request.Builder()
.url(sas.sasUrl)
.put(data.toRequestBody(contentType.toMediaType()))
.header("x-ms-blob-type", "BlockBlob")
.header("x-ms-blob-content-type", contentType)
.build()
val response = uploadClient.newCall(request).execute()
if (response.isSuccessful) sas.blobUrl else null
} catch (_: Exception) {
null
}
}
/**
* Upload audio data (convenience method).
*/
suspend fun uploadAudio(data: ByteArray, fileName: String): String? {
return upload(data, "audio", fileName, "audio/wav")
}
}

View File

@ -0,0 +1,97 @@
package com.bytelyst.platform
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
/**
* Crash reporter captures uncaught exceptions and reports to telemetry.
*
* Installs a [Thread.UncaughtExceptionHandler] that:
* 1. Saves crash info to SharedPreferences
* 2. On next app launch, sends the crash report to platform-service telemetry
* 3. Delegates to the previous handler (so the system can still show ANR/crash dialogs)
*
* Mirrors the Swift BLCrashReporter API (MetricKit equivalent for Android).
*/
class BLCrashReporter(
context: Context,
private val config: BLPlatformConfig,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("${config.productId}_crash_reporter", Context.MODE_PRIVATE)
private val client = BLPlatformClient(config)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val json = Json { encodeDefaults = true }
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
companion object {
private const val KEY_PENDING_CRASH = "pending_crash"
}
/**
* Install the crash handler and send any pending crash reports.
* Call once from Application.onCreate().
*/
fun install() {
sendPendingCrashReport()
installHandler()
}
private fun installHandler() {
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
val sw = StringWriter()
throwable.printStackTrace(PrintWriter(sw))
val crashData = buildJsonObject {
put("id", UUID.randomUUID().toString())
put("productId", config.productId)
put("platform", config.platform)
put("timestamp", isoFormat.format(Date()))
put("thread", thread.name)
put("exception", throwable.javaClass.name)
put("message", throwable.message ?: "")
put("stackTrace", sw.toString().take(4096))
}
// Persist synchronously (we may be about to die)
prefs.edit().putString(KEY_PENDING_CRASH, json.encodeToString(crashData)).commit()
// Delegate to previous handler
previousHandler?.uncaughtException(thread, throwable)
}
}
private fun sendPendingCrashReport() {
val pending = prefs.getString(KEY_PENDING_CRASH, null) ?: return
prefs.edit().remove(KEY_PENDING_CRASH).apply()
scope.launch {
try {
val body = """{"productId":"${config.productId}","events":[$pending]}"""
client.fireAndForget("POST", "/telemetry/events", body)
} catch (_: Exception) {
// Best-effort — don't re-queue
}
}
}
}

View File

@ -0,0 +1,90 @@
package com.bytelyst.platform
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* Feature flag client for platform-service.
*
* Polls GET /api/flags/poll on a configurable interval and caches results
* in memory. Consumers call [isEnabled] with a flag key.
*
* Mirrors the Swift BLFeatureFlagClient API.
*/
class BLFeatureFlagClient(
private val config: BLPlatformConfig,
private val pollIntervalMs: Long = 5 * 60 * 1000L,
) {
@Serializable
private data class FlagResponse(val flags: Map<String, Boolean> = emptyMap())
private val json = Json { ignoreUnknownKeys = true }
private val client = BLPlatformClient(config)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var pollJob: Job? = null
@Volatile
private var flags: Map<String, Boolean> = emptyMap()
private var userId: String? = null
// ── Public API ───────────────────────────────────────────
/**
* Initialize and start polling. Call once at app startup.
*/
fun init(userId: String? = null) {
this.userId = userId
scope.launch { fetchFlags() }
startPolling()
}
fun isEnabled(key: String): Boolean = flags[key] == true
fun getAllFlags(): Map<String, Boolean> = flags.toMap()
/**
* Force a refresh of feature flags.
*/
suspend fun refresh() {
fetchFlags()
}
fun stop() {
pollJob?.cancel()
pollJob = null
}
// ── Private ──────────────────────────────────────────────
private fun startPolling() {
if (pollJob != null) return
pollJob = scope.launch {
while (isActive) {
delay(pollIntervalMs)
fetchFlags()
}
}
}
private suspend fun fetchFlags() {
try {
val qs = buildString {
append("?platform=${config.platform}")
userId?.let { append("&userId=$it") }
}
val response = client.request("GET", "/flags/poll$qs", skipAuth = true)
val result = json.decodeFromString<FlagResponse>(response)
flags = result.flags
} catch (_: Exception) {
// Keep existing flags on failure
}
}
}

View File

@ -0,0 +1,41 @@
package com.bytelyst.platform
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* Kill switch client for platform-service.
*
* Checks GET /api/flags/kill-switch to determine if the app should be disabled.
* Fail-open: returns [KillSwitchResult.ok] on any error.
*
* Mirrors the Swift BLKillSwitchClient API.
*/
class BLKillSwitchClient(
private val config: BLPlatformConfig,
) {
@Serializable
data class KillSwitchResult(
val disabled: Boolean = false,
val message: String? = null,
) {
companion object {
fun ok() = KillSwitchResult(disabled = false, message = null)
}
}
private val json = Json { ignoreUnknownKeys = true }
private val client = BLPlatformClient(config)
/**
* Check if the app is disabled. Fail-open on any error.
*/
suspend fun check(): KillSwitchResult {
return try {
val response = client.request("GET", "/flags/kill-switch?platform=${config.platform}", skipAuth = true)
json.decodeFromString<KillSwitchResult>(response)
} catch (_: Exception) {
KillSwitchResult.ok()
}
}
}

View File

@ -0,0 +1,82 @@
package com.bytelyst.platform
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.URLEncoder
/**
* License client for platform-service.
*
* Activates and checks license keys via /api/licenses endpoints.
* URL-encodes the license key in the path to handle special characters.
*
* Mirrors the Swift BLLicenseClient API.
*/
class BLLicenseClient(
private val config: BLPlatformConfig,
private val tokenProvider: () -> String? = { null },
) {
@Serializable
data class LicenseStatus(
val valid: Boolean = false,
val plan: String = "free",
val expiresAt: String? = null,
val message: String? = null,
)
@Serializable
data class ActivationResult(
val success: Boolean = false,
val plan: String = "free",
val message: String? = null,
)
private val json = Json { ignoreUnknownKeys = true }
private val client = BLPlatformClient(config, tokenProvider)
/**
* Activate a license key.
*/
suspend fun activate(licenseKey: String): ActivationResult {
return try {
val encoded = URLEncoder.encode(licenseKey, "UTF-8")
val body = json.encodeToString(
kotlinx.serialization.builtins.MapSerializer(
kotlinx.serialization.builtins.serializer<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
mapOf("productId" to config.productId),
)
val response = client.request("POST", "/licenses/$encoded/activate", body)
json.decodeFromString<ActivationResult>(response)
} catch (e: Exception) {
ActivationResult(success = false, message = e.message)
}
}
/**
* Check the status of a license key.
*/
suspend fun checkStatus(licenseKey: String): LicenseStatus {
return try {
val encoded = URLEncoder.encode(licenseKey, "UTF-8")
val response = client.request("GET", "/licenses/$encoded/status")
json.decodeFromString<LicenseStatus>(response)
} catch (e: Exception) {
LicenseStatus(valid = false, message = e.message)
}
}
/**
* Deactivate a license key.
*/
suspend fun deactivate(licenseKey: String): Boolean {
return try {
val encoded = URLEncoder.encode(licenseKey, "UTF-8")
client.request("POST", "/licenses/$encoded/deactivate")
true
} catch (_: Exception) {
false
}
}
}

View File

@ -0,0 +1,99 @@
package com.bytelyst.platform
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Generic HTTP client for platform-service.
*
* Injects auth token, x-product-id, and x-request-id on every request.
* Supports fire-and-forget mode for telemetry-style calls.
*/
class BLPlatformClient(
private val config: BLPlatformConfig,
private val tokenProvider: () -> String? = { null },
) {
val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val httpClient = OkHttpClient.Builder()
.connectTimeout(config.timeoutMs, TimeUnit.MILLISECONDS)
.writeTimeout(config.timeoutMs, TimeUnit.MILLISECONDS)
.readTimeout(config.timeoutMs, TimeUnit.MILLISECONDS)
.build()
/**
* Execute a request and return the response body as a String.
* Throws on non-2xx responses.
*/
suspend fun request(
method: String,
path: String,
body: String? = null,
extraHeaders: Map<String, String> = emptyMap(),
skipAuth: Boolean = false,
): String = withContext(Dispatchers.IO) {
val builder = Request.Builder()
.url("${config.baseUrl}$path")
.header("Content-Type", "application/json")
.header("X-Product-Id", config.productId)
.header("X-Request-Id", UUID.randomUUID().toString())
if (!skipAuth) {
tokenProvider()?.let { token ->
builder.header("Authorization", "Bearer $token")
}
}
extraHeaders.forEach { (k, v) -> builder.header(k, v) }
val requestBody = body?.toRequestBody("application/json".toMediaType())
when (method.uppercase()) {
"GET" -> builder.get()
"POST" -> builder.post(requestBody ?: "".toRequestBody(null))
"PUT" -> builder.put(requestBody ?: "".toRequestBody(null))
"DELETE" -> if (requestBody != null) builder.delete(requestBody) else builder.delete()
"PATCH" -> builder.patch(requestBody ?: "".toRequestBody(null))
else -> builder.method(method, requestBody)
}
val response = httpClient.newCall(builder.build()).execute()
val responseBody = response.body?.string() ?: ""
if (!response.isSuccessful) {
throw BLApiException(response.code, responseBody)
}
responseBody
}
/**
* Fire-and-forget: execute request, silently swallow errors.
*/
suspend fun fireAndForget(
method: String,
path: String,
body: String? = null,
extraHeaders: Map<String, String> = emptyMap(),
) {
try {
request(method, path, body, extraHeaders)
} catch (_: Exception) {
// Silently swallow — fire-and-forget
}
}
}
/**
* Exception thrown when the platform API returns a non-2xx status.
*/
class BLApiException(
val statusCode: Int,
val responseBody: String,
) : Exception("Platform API error $statusCode: $responseBody")

View File

@ -0,0 +1,27 @@
package com.bytelyst.platform
/**
* Product-specific configuration for the ByteLyst platform SDK.
*
* Each Android app creates one instance at startup and passes it to
* all SDK components via DI (Hilt, Koin, or manual).
*/
data class BLPlatformConfig(
/** Product identifier (e.g., "chronomind", "lysnrai", "mindlyst"). */
val productId: String,
/** Platform-service base URL (e.g., "http://localhost:4003/api"). */
val baseUrl: String,
/** Platform string for telemetry (e.g., "android", "wear_os"). */
val platform: String = "android",
/** Channel string for telemetry (e.g., "native", "keyboard"). */
val channel: String = "native",
/** Application ID / bundle ID (e.g., "com.chronomind.app"). */
val applicationId: String,
/** Request timeout in milliseconds. Default: 15 seconds. */
val timeoutMs: Long = 15_000L,
)

View File

@ -0,0 +1,51 @@
package com.bytelyst.platform
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
/**
* EncryptedSharedPreferences-backed secure storage.
*
* Replaces Keychain on iOS. Uses Android Keystore for key material.
* Each app gets its own namespace via [applicationId].
*/
class BLSecureStore(
context: Context,
applicationId: String,
) {
private val prefs: SharedPreferences = try {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create(
"${applicationId}_secure_store",
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (_: Exception) {
// Fallback to plain SharedPreferences if encryption fails (e.g., test environment)
context.getSharedPreferences("${applicationId}_secure_store_fallback", Context.MODE_PRIVATE)
}
fun save(key: String, value: String): Boolean {
return prefs.edit().putString(key, value).commit()
}
fun read(key: String): String? {
return prefs.getString(key, null)
}
fun delete(key: String): Boolean {
return prefs.edit().remove(key).commit()
}
fun clear(): Boolean {
return prefs.edit().clear().commit()
}
fun contains(key: String): Boolean {
return prefs.contains(key)
}
}

View File

@ -0,0 +1,111 @@
package com.bytelyst.platform
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Generic offline-first sync engine.
*
* Apps implement [BLSyncAdapter] for their specific data type,
* then [BLSyncEngine] handles the pull-merge-push cycle.
*
* Mirrors the Swift BLSyncEngine + BLSyncAdapter API.
*/
class BLSyncEngine<T>(
private val adapter: BLSyncAdapter<T>,
private val config: BLPlatformConfig,
tokenProvider: () -> String? = { null },
) {
private val client = BLPlatformClient(config, tokenProvider)
/**
* Run a full sync cycle: pull merge push.
*
* @return [SyncResult] with counts of pulled, pushed, and conflicted items.
*/
suspend fun sync(): SyncResult = withContext(Dispatchers.IO) {
var pulled = 0
var pushed = 0
var conflicts = 0
try {
// Step 1: Pull remote changes
val lastSync = adapter.getLastSyncTimestamp()
val pullPath = adapter.pullPath(lastSync)
val response = client.request("GET", pullPath)
val remoteItems = adapter.deserializePullResponse(response)
pulled = remoteItems.size
// Step 2: Merge remote into local
for (item in remoteItems) {
val merged = adapter.merge(item)
if (!merged) conflicts++
}
// Step 3: Push local changes
val localChanges = adapter.getLocalChanges()
for (item in localChanges) {
try {
val body = adapter.serializeForPush(item)
val method = adapter.pushMethod(item)
val path = adapter.pushPath(item)
client.request(method, path, body)
adapter.markSynced(item)
pushed++
} catch (_: Exception) {
// Individual push failure — will retry next sync
}
}
// Step 4: Update last sync timestamp
adapter.setLastSyncTimestamp(System.currentTimeMillis())
} catch (_: Exception) {
// Full sync failure — will retry next time
}
SyncResult(pulled = pulled, pushed = pushed, conflicts = conflicts)
}
data class SyncResult(
val pulled: Int = 0,
val pushed: Int = 0,
val conflicts: Int = 0,
)
}
/**
* Adapter interface for [BLSyncEngine].
*
* Each app implements this for their domain model (timers, sessions, etc.).
*/
interface BLSyncAdapter<T> {
/** Get the timestamp of the last successful sync (epoch millis), or null if never synced. */
fun getLastSyncTimestamp(): Long?
/** Set the last sync timestamp after a successful sync. */
fun setLastSyncTimestamp(timestamp: Long)
/** Build the pull endpoint path, optionally including a since parameter. */
fun pullPath(since: Long?): String
/** Deserialize the pull response JSON into a list of remote items. */
fun deserializePullResponse(json: String): List<T>
/** Merge a remote item into local storage. Return true if merged cleanly, false if conflict. */
fun merge(remoteItem: T): Boolean
/** Get local items that have been modified since last sync. */
fun getLocalChanges(): List<T>
/** Serialize a local item for push. */
fun serializeForPush(item: T): String
/** HTTP method for pushing an item (POST for new, PUT for update). */
fun pushMethod(item: T): String
/** Build the push endpoint path for an item. */
fun pushPath(item: T): String
/** Mark an item as synced (clear dirty flag). */
fun markSynced(item: T)
}

View File

@ -0,0 +1,191 @@
package com.bytelyst.platform
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
/**
* Telemetry client for platform-service.
*
* Queues events locally and flushes in batches to POST /api/telemetry/events.
* Events persist in SharedPreferences to survive process death.
* Fire-and-forget: errors never surface to the user.
*
* Mirrors the Swift BLTelemetryClient API.
*/
class BLTelemetryClient(
private val config: BLPlatformConfig,
context: Context,
private val maxQueueSize: Int = 50,
private val flushIntervalMs: Long = 30_000L,
) {
// ── Event schema ─────────────────────────────────────────
@Serializable
data class TelemetryEvent(
val id: String,
val productId: String,
val anonymousInstallId: String,
val sessionId: String,
val platform: String,
val channel: String,
val osFamily: String,
val osVersion: String,
val appVersion: String,
val buildNumber: String,
val releaseChannel: String,
val eventType: String,
val module: String,
val eventName: String,
val feature: String? = null,
val message: String? = null,
val errorCode: String? = null,
val errorDomain: String? = null,
val tags: Map<String, String>? = null,
val metrics: Map<String, Double>? = null,
val occurredAt: String,
)
@Serializable
private data class EventBatch(val events: List<TelemetryEvent>)
// ── State ────────────────────────────────────────────────
private val prefs: SharedPreferences =
context.getSharedPreferences("${config.productId}_telemetry", Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val queue = mutableListOf<TelemetryEvent>()
private var flushJob: Job? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var sessionId = UUID.randomUUID().toString()
private val client = BLPlatformClient(config)
private val installId: String by lazy {
val key = "install_id"
prefs.getString(key, null) ?: UUID.randomUUID().toString().also {
prefs.edit().putString(key, it).apply()
}
}
private val osVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
private val appVersion: String
private val buildNumber: String
init {
val pm = context.packageManager
val pi = try { pm.getPackageInfo(context.packageName, 0) } catch (_: Exception) { null }
appVersion = pi?.versionName ?: "0.0.0"
buildNumber = (pi?.longVersionCode ?: 0).toString()
}
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
// ── Lifecycle ────────────────────────────────────────────
fun start() {
if (flushJob != null) return
flushJob = scope.launch {
while (isActive) {
delay(flushIntervalMs)
flush()
}
}
}
fun stop() {
flush()
flushJob?.cancel()
flushJob = null
}
fun newSession() {
sessionId = UUID.randomUUID().toString()
}
// ── Public API ───────────────────────────────────────────
fun trackEvent(
eventType: String,
module: String,
name: String,
feature: String? = null,
message: String? = null,
errorCode: String? = null,
errorDomain: String? = null,
tags: Map<String, String>? = null,
metrics: Map<String, Double>? = null,
) {
val event = TelemetryEvent(
id = UUID.randomUUID().toString(),
productId = config.productId,
anonymousInstallId = installId,
sessionId = sessionId,
platform = config.platform,
channel = config.channel,
osFamily = "android",
osVersion = osVersion,
appVersion = appVersion,
buildNumber = buildNumber,
releaseChannel = "beta",
eventType = eventType,
module = module,
eventName = name,
feature = feature,
message = message?.take(512),
errorCode = errorCode,
errorDomain = errorDomain,
tags = tags,
metrics = metrics,
occurredAt = isoFormat.format(Date()),
)
synchronized(queue) {
queue.add(event)
if (queue.size >= maxQueueSize) {
flush()
}
}
}
fun trackScreen(screen: String) {
trackEvent("info", "navigation", "screen_view", tags = mapOf("screen" to screen))
}
// ── Flush ────────────────────────────────────────────────
fun flush() {
val batch: List<TelemetryEvent>
synchronized(queue) {
if (queue.isEmpty()) return
batch = queue.toList()
queue.clear()
}
scope.launch {
val body = json.encodeToString(EventBatch(batch))
client.fireAndForget(
"POST", "/telemetry/events", body,
extraHeaders = mapOf("X-Install-Token" to installId),
)
}
}
}

View File

@ -0,0 +1,21 @@
{
"name": "@bytelyst/offline-queue",
"version": "0.1.0",
"type": "module",
"description": "Browser/React Native-safe persistent offline retry queue",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run"
}
}

View File

@ -0,0 +1,143 @@
import { describe, it, expect, vi } from 'vitest';
import { createOfflineQueue } from './index.js';
function createMemoryStorage() {
const store: Record<string, string> = {};
return {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => {
store[k] = v;
},
_store: store,
};
}
describe('createOfflineQueue', () => {
it('should start with zero length', () => {
const storage = createMemoryStorage();
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
expect(queue.length()).toBe(0);
});
it('should enqueue items', () => {
const storage = createMemoryStorage();
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { name: 'test' } });
expect(queue.length()).toBe(1);
queue.enqueue({ id: '2', action: 'update', path: '/sessions/2', payload: { name: 'test2' } });
expect(queue.length()).toBe(2);
});
it('should replace existing entry with same id + action', () => {
const storage = createMemoryStorage();
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { v: 1 } });
queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { v: 2 } });
expect(queue.length()).toBe(1);
});
it('should not replace entries with different action', () => {
const storage = createMemoryStorage();
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: {} });
queue.enqueue({ id: '1', action: 'update', path: '/sessions/1', payload: {} });
expect(queue.length()).toBe(2);
});
it('should flush all items successfully', async () => {
const storage = createMemoryStorage();
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
queue.enqueue({ id: '1', action: 'create', path: '/a', payload: { n: 1 } });
queue.enqueue({ id: '2', action: 'create', path: '/b', payload: { n: 2 } });
const executor = vi.fn().mockResolvedValue(undefined);
const result = await queue.flush(executor);
expect(result.flushed).toBe(2);
expect(result.failed).toBe(0);
expect(queue.length()).toBe(0);
expect(executor).toHaveBeenCalledTimes(2);
});
it('should retry failed items on next flush', async () => {
const storage = createMemoryStorage();
const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxRetries: 3 });
queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} });
const failingExecutor = vi.fn().mockRejectedValue(new Error('offline'));
const result = await queue.flush(failingExecutor);
expect(result.flushed).toBe(0);
expect(result.failed).toBe(1);
expect(queue.length()).toBe(1);
});
it('should drop items after max retries', async () => {
const storage = createMemoryStorage();
const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxRetries: 2 });
queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} });
const failingExecutor = vi.fn().mockRejectedValue(new Error('offline'));
// Retry 1
await queue.flush(failingExecutor);
expect(queue.length()).toBe(1);
// Retry 2 — should be dropped
await queue.flush(failingExecutor);
expect(queue.length()).toBe(0);
});
it('should cap queue at maxQueueSize', () => {
const storage = createMemoryStorage();
const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxQueueSize: 3 });
for (let i = 0; i < 5; i++) {
queue.enqueue({ id: `${i}`, action: 'create', path: `/item/${i}`, payload: {} });
}
expect(queue.length()).toBe(3);
});
it('should persist to storage', () => {
const storage = createMemoryStorage();
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
queue.enqueue({ id: '1', action: 'create', path: '/a', payload: { x: 1 } });
const stored = JSON.parse(storage._store['test-q']);
expect(stored).toHaveLength(1);
expect(stored[0].id).toBe('1');
});
it('should clear the queue', () => {
const storage = createMemoryStorage();
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} });
queue.enqueue({ id: '2', action: 'create', path: '/b', payload: {} });
expect(queue.length()).toBe(2);
queue.clear();
expect(queue.length()).toBe(0);
});
it('should return empty result when flushing empty queue', async () => {
const storage = createMemoryStorage();
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
const executor = vi.fn();
const result = await queue.flush(executor);
expect(result.flushed).toBe(0);
expect(result.failed).toBe(0);
expect(executor).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,166 @@
/**
* Persistent offline retry queue for browser and React Native.
*
* When an API call fails (offline, timeout, etc.), the operation is
* queued in configurable storage and retried on the next flush.
*
* No Node.js, React, or React Native dependencies.
*
* @example
* ```ts
* import { createOfflineQueue } from '@bytelyst/offline-queue';
*
* const queue = createOfflineQueue({
* storageKey: 'nomgap-offline-queue',
* storage: mmkvStorage, // or localStorage
* });
*
* // On API failure:
* queue.enqueue({ id: 'sess-1', action: 'create', path: '/sessions', payload: { ... } });
*
* // On app foreground / auth success:
* const result = await queue.flush(async (action, path, payload) => {
* await apiClient.request(action === 'create' ? 'POST' : 'PUT', path, payload);
* });
* ```
*/
// ── Types ────────────────────────────────────────────────────
export interface QueueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface OfflineQueueConfig {
/** Storage key for persisting the queue. */
storageKey: string;
/** Storage adapter (localStorage, MMKV, AsyncStorage wrapper, etc.). */
storage: QueueStorage;
/** Maximum retry attempts per item. Default: 5. */
maxRetries?: number;
/** Maximum queue size. Oldest items are dropped when exceeded. Default: 50. */
maxQueueSize?: number;
}
export interface QueueItem {
id: string;
action: string;
path: string;
payload: Record<string, unknown>;
enqueuedAt: number;
retryCount: number;
}
export interface FlushResult {
flushed: number;
failed: number;
}
export interface OfflineQueue {
/** Enqueue a failed operation for later retry. Replaces existing entry with same id + action. */
enqueue(item: {
id: string;
action: string;
path: string;
payload: Record<string, unknown>;
}): void;
/** Flush the queue — retry all pending items via the provided executor. */
flush(
executor: (action: string, path: string, payload: Record<string, unknown>) => Promise<void>
): Promise<FlushResult>;
/** Get current queue length. */
length(): number;
/** Clear the entire queue. */
clear(): void;
}
// ── Factory ──────────────────────────────────────────────────
export function createOfflineQueue(config: OfflineQueueConfig): OfflineQueue {
const { storageKey, storage, maxRetries = 5, maxQueueSize = 50 } = config;
function loadQueue(): QueueItem[] {
try {
const raw = storage.getItem(storageKey);
if (!raw) return [];
return JSON.parse(raw) as QueueItem[];
} catch {
return [];
}
}
function saveQueue(queue: QueueItem[]): void {
try {
storage.setItem(storageKey, JSON.stringify(queue));
} catch {
// Storage unavailable
}
}
function enqueue(item: {
id: string;
action: string;
path: string;
payload: Record<string, unknown>;
}): void {
const queue = loadQueue();
// Replace existing entry for same entity + action
const filtered = queue.filter(q => !(q.id === item.id && q.action === item.action));
// Cap queue size
if (filtered.length >= maxQueueSize) {
filtered.shift();
}
filtered.push({
...item,
enqueuedAt: Date.now(),
retryCount: 0,
});
saveQueue(filtered);
}
async function flush(
executor: (action: string, path: string, payload: Record<string, unknown>) => Promise<void>
): Promise<FlushResult> {
const queue = loadQueue();
if (queue.length === 0) return { flushed: 0, failed: 0 };
let flushed = 0;
const remaining: QueueItem[] = [];
for (const item of queue) {
try {
await executor(item.action, item.path, item.payload);
flushed++;
} catch {
if (item.retryCount + 1 < maxRetries) {
remaining.push({ ...item, retryCount: item.retryCount + 1 });
}
// else: silently drop — too many retries
}
}
saveQueue(remaining);
return { flushed, failed: remaining.length };
}
function length(): number {
return loadQueue().length;
}
function clear(): void {
saveQueue([]);
}
return { enqueue, flush, length, clear };
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,21 @@
{
"name": "@bytelyst/platform-client",
"version": "0.1.0",
"type": "module",
"description": "Browser/React Native-safe typed fetch wrapper for platform-service",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run"
}
}

View File

@ -0,0 +1,162 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createPlatformClient, ApiError } from './index.js';
describe('createPlatformClient', () => {
const baseConfig = {
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
getAccessToken: () => 'test-token',
};
afterEach(() => {
vi.restoreAllMocks();
});
it('should make GET requests with auth header', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ items: [] }),
});
vi.stubGlobal('fetch', fetchMock);
const api = createPlatformClient(baseConfig);
const result = await api.get('/items');
expect(result).toEqual({ items: [] });
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:4003/api/items',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
'x-product-id': 'testapp',
}),
})
);
});
it('should make POST requests with body', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ id: '1' }),
});
vi.stubGlobal('fetch', fetchMock);
const api = createPlatformClient(baseConfig);
const result = await api.post('/items', { name: 'test' });
expect(result).toEqual({ id: '1' });
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:4003/api/items',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'test' }),
})
);
});
it('should handle 204 No Content', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 204,
json: () => Promise.reject(new Error('no body')),
})
);
const api = createPlatformClient(baseConfig);
const result = await api.del('/items/1');
expect(result).toBeUndefined();
});
it('should throw ApiError on non-OK response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ message: 'Bad request' }),
})
);
const api = createPlatformClient(baseConfig);
await expect(api.get('/items')).rejects.toThrow(ApiError);
try {
await api.get('/items');
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
expect((e as ApiError).status).toBe(400);
}
});
it('should attempt token refresh on 401', async () => {
let callCount = 0;
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Unauthorized' }),
});
}
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ refreshed: true }),
});
})
);
const refreshFn = vi.fn().mockResolvedValue(true);
const api = createPlatformClient({
...baseConfig,
refreshAccessToken: refreshFn,
});
const result = await api.get('/items');
expect(refreshFn).toHaveBeenCalledOnce();
expect(result).toEqual({ refreshed: true });
});
it('should not include auth header when token is null', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({}),
});
vi.stubGlobal('fetch', fetchMock);
const api = createPlatformClient({
...baseConfig,
getAccessToken: () => null,
});
await api.get('/public/items');
const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>;
expect(headers['Authorization']).toBeUndefined();
});
it('should include x-request-id header', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({}),
});
vi.stubGlobal('fetch', fetchMock);
const api = createPlatformClient(baseConfig);
await api.get('/items');
const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>;
expect(headers['x-request-id']).toBeDefined();
expect(headers['x-request-id'].length).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,156 @@
/**
* Browser/React Native-safe typed fetch wrapper for platform-service.
*
* Client-side counterpart to @bytelyst/api-client (which is server-side).
* Uses bearer tokens from storage (not httpOnly cookies).
* Includes auto-retry on 401 via token refresh.
*
* @example
* ```ts
* import { createPlatformClient } from '@bytelyst/platform-client';
*
* const api = createPlatformClient({
* baseUrl: 'http://localhost:4003/api',
* productId: 'nomgap',
* getAccessToken: () => authClient.getAccessToken(),
* refreshAccessToken: () => authClient.refreshAccessToken(),
* });
*
* const sessions = await api.get<Session[]>('/fasting-sessions');
* await api.post('/fasting-sessions', { protocol: '16:8' });
* ```
*/
// ── Types ────────────────────────────────────────────────────
export interface PlatformClientConfig {
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
baseUrl: string;
/** Product identifier sent as x-product-id header. */
productId: string;
/** Function that returns the current access token, or null. */
getAccessToken: () => string | null;
/** Optional function to refresh the access token. Returns true on success. */
refreshAccessToken?: () => Promise<boolean>;
/** Request timeout in milliseconds. Default: 15000. */
timeoutMs?: number;
}
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly body: unknown,
message?: string
) {
super(message ?? `API error ${status}`);
this.name = 'ApiError';
}
}
export interface PlatformClient {
get<T = unknown>(path: string, headers?: Record<string, string>): Promise<T>;
post<T = unknown>(path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
put<T = unknown>(path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
del<T = unknown>(path: string, headers?: Record<string, string>): Promise<T>;
request<T = unknown>(
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>
): Promise<T>;
}
// ── UUID helper ──────────────────────────────────────────────
function uuid(): string {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
// ── Factory ──────────────────────────────────────────────────
export function createPlatformClient(config: PlatformClientConfig): PlatformClient {
const { baseUrl, productId, getAccessToken, refreshAccessToken, timeoutMs = 15_000 } = config;
async function doRequest<T>(
method: string,
path: string,
body?: unknown,
extraHeaders?: Record<string, string>,
isRetry = false
): Promise<T> {
const url = `${baseUrl}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-product-id': productId,
'x-request-id': uuid(),
...extraHeaders,
};
const token = getAccessToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await globalThis.fetch(url, {
method,
headers,
body: body != null ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (res.status === 204) return undefined as T;
const json = await res.json().catch(() => ({}));
// Auto-refresh on 401 (once)
if (res.status === 401 && !isRetry && !path.startsWith('/auth/') && refreshAccessToken) {
clearTimeout(timer);
const refreshed = await refreshAccessToken();
if (refreshed) {
return doRequest<T>(method, path, body, extraHeaders, true);
}
}
if (!res.ok) {
throw new ApiError(
res.status,
json,
(json as Record<string, string>).message ?? `HTTP ${res.status}`
);
}
return json as T;
} finally {
clearTimeout(timer);
}
}
return {
get: <T = unknown>(path: string, headers?: Record<string, string>) =>
doRequest<T>('GET', path, undefined, headers),
post: <T = unknown>(path: string, body?: unknown, headers?: Record<string, string>) =>
doRequest<T>('POST', path, body, headers),
put: <T = unknown>(path: string, body?: unknown, headers?: Record<string, string>) =>
doRequest<T>('PUT', path, body, headers),
del: <T = unknown>(path: string, headers?: Record<string, string>) =>
doRequest<T>('DELETE', path, undefined, headers),
request: <T = unknown>(
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>
) => doRequest<T>(method, path, body, headers),
};
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

8
pnpm-lock.yaml generated
View File

@ -379,10 +379,18 @@ importers:
specifier: ^10.6.0
version: 10.6.0(fastify@5.7.4)
packages/feature-flag-client: {}
packages/kill-switch-client: {}
packages/logger: {}
packages/monitoring: {}
packages/offline-queue: {}
packages/platform-client: {}
packages/react-auth:
dependencies:
'@bytelyst/api-client':