@honch/react-native-relay 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # Honch React Native Relay
2
+
3
+ Release-candidate React Native relay package for companion apps that receive Honch relay frames from offline devices, durably assemble completed device messages, ACK durable receipt, and upload to Honch Capture.
4
+
5
+ React Native Relay is not a device analytics SDK. Use it only when firmware cannot upload directly.
6
+
7
+ ## Status
8
+
9
+ Release-candidate `0.1.0`. Production use still requires validation inside the consuming iOS and Android host apps. See [`PRODUCTION_READINESS.md`](PRODUCTION_READINESS.md).
10
+
11
+ ## Capture Contract
12
+
13
+ Relay uploads use the canonical Capture endpoint:
14
+
15
+ ```text
16
+ POST /capture
17
+ Content-Type: application/vnd.honch.chunk
18
+ X-Honch-Project-Key: <project_api_key>
19
+ X-Honch-Stream-Id: <relay_stream_id>
20
+ X-Honch-Relay-Id: <mobile_relay_id>
21
+ X-Honch-Relay-SDK-Platform: react-native
22
+ X-Honch-Relay-SDK-Version: <package_version>
23
+ ```
24
+
25
+ Firmware relay chunks carry compact message bytes. The mobile relay validates and reassembles relay frames, durably stores the completed compact message, then uploads one or more HTTP chunk frames. It may re-chunk for HTTP but must not rewrite the compact device message body.
26
+
27
+ ## Setup
28
+
29
+ Install this package and a production durable store into a React Native host app:
30
+
31
+ ```bash
32
+ bun add @honch/react-native-relay
33
+ ```
34
+
35
+ The consuming app must register native modules and run its normal iOS/Android dependency installation flow.
36
+
37
+ ## Durable Storage
38
+
39
+ Production mobile apps should use MMKV. It is an optional peer dependency, so
40
+ install it only when using `createMmkvRelayStore`:
41
+
42
+ ```bash
43
+ bun add react-native-mmkv react-native-nitro-modules
44
+ ```
45
+
46
+ ```ts
47
+ import { createMMKV } from "react-native-mmkv";
48
+ import { createMmkvRelayStore } from "@honch/react-native-relay";
49
+
50
+ const relayStore = createMmkvRelayStore(createMMKV({ id: "honch-relay" }));
51
+ ```
52
+
53
+ The relay store only requires an MMKV-compatible object with `getString`,
54
+ `set`, and `remove`, so host apps on `react-native-mmkv` v2, v3, or v4 can use
55
+ their existing MMKV installation.
56
+
57
+ MMKV relay storage uses per-chunk and per-message records with a small index, so receipt does not rewrite a full queue blob for every chunk. Binary frame, payload, and message bodies are stored as base64 strings instead of JSON number arrays. By default it retains up to 4,096 chunks and 1,024 completed messages with no time-based expiry, dropping the oldest entries once a cap is reached to bound offline growth. Time-based expiry is opt-in via `ttlMs`. Override these limits when the host app has a smaller storage budget:
58
+
59
+ ```ts
60
+ const relayStore = createMmkvRelayStore(createMMKV({ id: "honch-relay" }), {
61
+ keyPrefix: "com.example.honch.relay",
62
+ maxChunks: 1024,
63
+ maxCompleteMessages: 256,
64
+ ttlMs: 3 * 24 * 60 * 60 * 1000
65
+ });
66
+ ```
67
+
68
+ Use `keyPrefix` when sharing an MMKV instance with host app data. The default
69
+ prefix is `honch.relay`.
70
+
71
+ All bundled stores share the same retention model: count caps on chunks and
72
+ completed messages with drop-oldest eviction and no time-based expiry by default.
73
+ `createMemoryDurableStore` and `createJsonFileRelayStore` accept `maxChunks` and
74
+ `maxCompleteMessages` (defaults 4,096 / 1,024); `createMmkvRelayStore` adds the
75
+ optional `ttlMs`.
76
+
77
+ Completed messages and incomplete assemblies remain pending across app restarts until Capture accepts or permanently rejects them, unless they age out or the configured queue bounds require dropping oldest state. Retry attempts and next-attempt timestamps are stored with completed messages, so app restarts do not reset relay backoff or hammer Capture while a message is still inside its retry delay.
78
+
79
+ ## Host-Owned Bluetooth
80
+
81
+ The relay package does not scan, connect, subscribe, request BLE permissions, or write BLE characteristics for the host app. Production apps should keep their existing Bluetooth stack and pass relay frames into the SDK:
82
+
83
+ ```ts
84
+ import { createMobileRelay } from "@honch/react-native-relay";
85
+
86
+ const relay = createMobileRelay({
87
+ durableStore,
88
+ uploaderConfig,
89
+ schedulerNative
90
+ });
91
+
92
+ hostBle.onRelayFrame(async ({ deviceId, frameBytes }) => {
93
+ await relay.receiveFrame(deviceId, frameBytes, {
94
+ acknowledge: async ({ ackBytes }) => {
95
+ await hostBle.writeRelayAck(deviceId, ackBytes);
96
+ }
97
+ });
98
+ });
99
+ ```
100
+
101
+ ACK bytes are emitted only after the received relay message has been durably assembled and stored. Malformed frames reject and do not produce ACK bytes. Duplicate completed frames can be ACKed again so firmware retries can settle.
102
+
103
+ The Honch relay BLE UUIDs and frame format remain defined by `spec/relay-chunks.md`; the host app owns how those characteristics are discovered and wired into its device UX.
104
+
105
+ ## Native Host Requirements
106
+
107
+ iOS:
108
+
109
+ - Add the Bluetooth usage strings/background modes required by the host app's own BLE implementation.
110
+ - iOS upload scheduling is foreground-only. Call `drainUploads()` from the host
111
+ app foreground lifecycle; `startUploadScheduler()` drains immediately without
112
+ native background scheduling when no scheduler binding is configured.
113
+
114
+ Android:
115
+
116
+ - Request the Bluetooth permissions required by the host app's own BLE implementation.
117
+ - This package does not merge BLE, location, or notification permissions into
118
+ the host manifest.
119
+ - Keep `androidx.work:work-runtime` available for scheduled upload drains.
120
+ - Register the package through the consuming React Native Android host.
121
+ - Register a headless JS task named `HonchRelayUpload` that calls the same durable queue drain path used by foreground upload drains.
122
+
123
+ ## Upload Draining
124
+
125
+ `createMobileRelay` exposes:
126
+
127
+ - `drainUploads()` to drain pending messages once;
128
+ - `startUploadScheduler()` to start scheduled drains;
129
+ - `stopUploadScheduler()` to cancel scheduled drain work.
130
+
131
+ Retryable upload failures keep messages pending and schedule the next native upload attempt with relay backoff.
132
+ If Capture returns `Retry-After` on a retryable response, that delay takes precedence over the local exponential backoff.
133
+ Upload retry timing and attempt state stay in the JavaScript relay drain path and durable store. Android WorkManager only retries failures to launch the headless task, and the headless task timeout is capped at 10 seconds to bound wake-lock hold time.
134
+
135
+ Android scheduled drains start the `HonchRelayUpload` headless JS task from WorkManager. Register the task in the host app entrypoint and call the app-owned relay singleton:
136
+
137
+ ```ts
138
+ import { AppRegistry } from "react-native";
139
+
140
+ AppRegistry.registerHeadlessTask("HonchRelayUpload", () => async () => {
141
+ await relay.drainUploads();
142
+ });
143
+ ```
144
+
145
+ ## Test And Verification
146
+
147
+ ```bash
148
+ bun run test
149
+ bun run typecheck
150
+ bun run e2e:capture
151
+ bun run verify:ios:native
152
+ bun run verify:android:native
153
+ ```
154
+
155
+ These checks do not replace validation in a consuming host app. Production validation must include BLE behavior, durable storage across app restart, retry preservation, accepted Capture response handling, and live Capture ingestion.
@@ -0,0 +1,30 @@
1
+ buildscript {
2
+ repositories {
3
+ google()
4
+ mavenCentral()
5
+ }
6
+ }
7
+
8
+ plugins {
9
+ id "com.android.library"
10
+ }
11
+
12
+ android {
13
+ namespace "io.honch.reactnativerelay"
14
+ compileSdkVersion 35
15
+
16
+ defaultConfig {
17
+ minSdkVersion 23
18
+ targetSdkVersion 35
19
+ }
20
+ }
21
+
22
+ repositories {
23
+ google()
24
+ mavenCentral()
25
+ }
26
+
27
+ dependencies {
28
+ compileOnly "com.facebook.react:react-android:+"
29
+ implementation "androidx.work:work-runtime:2.9.1"
30
+ }
@@ -0,0 +1 @@
1
+ android.useAndroidX=true
@@ -0,0 +1,12 @@
1
+ pluginManagement {
2
+ repositories {
3
+ google()
4
+ mavenCentral()
5
+ gradlePluginPortal()
6
+ }
7
+ plugins {
8
+ id "com.android.library" version "8.7.3"
9
+ }
10
+ }
11
+
12
+ rootProject.name = "HonchReactNativeRelayAndroid"
@@ -0,0 +1,7 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <application>
3
+ <service
4
+ android:name=".HonchRelayUploadTaskService"
5
+ android:exported="false" />
6
+ </application>
7
+ </manifest>
@@ -0,0 +1,67 @@
1
+ package io.honch.reactnativerelay;
2
+
3
+ import androidx.annotation.NonNull;
4
+ import androidx.work.ExistingWorkPolicy;
5
+ import androidx.work.OneTimeWorkRequest;
6
+ import androidx.work.WorkManager;
7
+
8
+ import com.facebook.react.bridge.Promise;
9
+ import com.facebook.react.bridge.ReactApplicationContext;
10
+ import com.facebook.react.bridge.ReactContextBaseJavaModule;
11
+ import com.facebook.react.bridge.ReactMethod;
12
+
13
+ import java.util.concurrent.TimeUnit;
14
+
15
+ public class HonchReactNativeRelayModule extends ReactContextBaseJavaModule {
16
+ public static final String NAME = "HonchReactNativeRelay";
17
+
18
+ private final ReactApplicationContext reactContext;
19
+
20
+ public HonchReactNativeRelayModule(ReactApplicationContext reactContext) {
21
+ super(reactContext);
22
+ this.reactContext = reactContext;
23
+ }
24
+
25
+ @NonNull
26
+ @Override
27
+ public String getName() {
28
+ return NAME;
29
+ }
30
+
31
+ @ReactMethod
32
+ public void scheduleUpload(double delayMs, Promise promise) {
33
+ try {
34
+ OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(HonchRelayUploadWorker.class)
35
+ .setInitialDelay((long)delayMs, TimeUnit.MILLISECONDS)
36
+ .setInputData(new androidx.work.Data.Builder().putLong("delayMs", (long)delayMs).build())
37
+ .addTag(HonchRelayUploadWorker.WORK_TAG)
38
+ .build();
39
+ WorkManager
40
+ .getInstance(reactContext)
41
+ .enqueueUniqueWork(HonchRelayUploadWorker.WORK_TAG, ExistingWorkPolicy.REPLACE, request);
42
+ promise.resolve(null);
43
+ } catch (RuntimeException error) {
44
+ promise.reject("schedule_upload_failed", error);
45
+ }
46
+ }
47
+
48
+ @ReactMethod
49
+ public void cancelUpload(Promise promise) {
50
+ try {
51
+ cancelScheduledUploads();
52
+ promise.resolve(null);
53
+ } catch (RuntimeException error) {
54
+ promise.reject("cancel_upload_failed", error);
55
+ }
56
+ }
57
+
58
+ @Override
59
+ public void invalidate() {
60
+ cancelScheduledUploads();
61
+ super.invalidate();
62
+ }
63
+
64
+ private void cancelScheduledUploads() {
65
+ WorkManager.getInstance(reactContext).cancelAllWorkByTag(HonchRelayUploadWorker.WORK_TAG);
66
+ }
67
+ }
@@ -0,0 +1,28 @@
1
+ package io.honch.reactnativerelay;
2
+
3
+ import androidx.annotation.NonNull;
4
+
5
+ import com.facebook.react.ReactPackage;
6
+ import com.facebook.react.bridge.NativeModule;
7
+ import com.facebook.react.bridge.ReactApplicationContext;
8
+ import com.facebook.react.uimanager.ViewManager;
9
+
10
+ import java.util.ArrayList;
11
+ import java.util.Collections;
12
+ import java.util.List;
13
+
14
+ public class HonchReactNativeRelayPackage implements ReactPackage {
15
+ @NonNull
16
+ @Override
17
+ public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) {
18
+ List<NativeModule> modules = new ArrayList<>();
19
+ modules.add(new HonchReactNativeRelayModule(reactContext));
20
+ return modules;
21
+ }
22
+
23
+ @NonNull
24
+ @Override
25
+ public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactContext) {
26
+ return Collections.emptyList();
27
+ }
28
+ }
@@ -0,0 +1,24 @@
1
+ package io.honch.reactnativerelay;
2
+
3
+ import android.content.Intent;
4
+
5
+ import androidx.annotation.Nullable;
6
+
7
+ import com.facebook.react.HeadlessJsTaskService;
8
+ import com.facebook.react.bridge.Arguments;
9
+ import com.facebook.react.bridge.WritableMap;
10
+ import com.facebook.react.jstasks.HeadlessJsTaskConfig;
11
+
12
+ public class HonchRelayUploadTaskService extends HeadlessJsTaskService {
13
+ public static final String TASK_NAME = "HonchRelayUpload";
14
+ public static final String EXTRA_DELAY_MS = "delayMs";
15
+ private static final long TASK_TIMEOUT_MS = 10000L;
16
+
17
+ @Nullable
18
+ @Override
19
+ protected HeadlessJsTaskConfig getTaskConfig(Intent intent) {
20
+ WritableMap data = Arguments.createMap();
21
+ data.putDouble(EXTRA_DELAY_MS, intent.getLongExtra(EXTRA_DELAY_MS, 0L));
22
+ return new HeadlessJsTaskConfig(TASK_NAME, data, TASK_TIMEOUT_MS, true);
23
+ }
24
+ }
@@ -0,0 +1,34 @@
1
+ package io.honch.reactnativerelay;
2
+
3
+ import android.content.Context;
4
+ import android.content.Intent;
5
+ import android.util.Log;
6
+
7
+ import androidx.annotation.NonNull;
8
+ import androidx.work.Worker;
9
+ import androidx.work.WorkerParameters;
10
+
11
+ public class HonchRelayUploadWorker extends Worker {
12
+ public static final String WORK_TAG = "honch-relay-upload";
13
+ private static final String TAG = "HonchRelayUpload";
14
+
15
+ public HonchRelayUploadWorker(@NonNull Context context, @NonNull WorkerParameters params) {
16
+ super(context, params);
17
+ }
18
+
19
+ @NonNull
20
+ @Override
21
+ public Result doWork() {
22
+ Context context = getApplicationContext();
23
+ Intent intent = new Intent(context, HonchRelayUploadTaskService.class);
24
+ intent.putExtra(HonchRelayUploadTaskService.EXTRA_DELAY_MS, getInputData().getLong("delayMs", 0L));
25
+
26
+ try {
27
+ context.startService(intent);
28
+ return Result.success();
29
+ } catch (RuntimeException error) {
30
+ Log.e(TAG, "Failed to start relay upload task", error);
31
+ return Result.retry();
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,132 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import {
3
+ SafeAreaView,
4
+ ScrollView,
5
+ StyleSheet,
6
+ Text,
7
+ TouchableOpacity,
8
+ View
9
+ } from "react-native";
10
+ import type { StoredRelayMessage } from "@honch/react-native-relay";
11
+ import { relay } from "./relay";
12
+
13
+ export default function App() {
14
+ const [status, setStatus] = useState("idle");
15
+ const [pending, setPending] = useState<StoredRelayMessage[]>([]);
16
+ const [lastError, setLastError] = useState<string>("none");
17
+ const [lastReceivedDeviceId, setLastReceivedDeviceId] = useState<string>("none");
18
+
19
+ useEffect(() => {
20
+ void refreshPending();
21
+ }, []);
22
+
23
+ async function refreshPending() {
24
+ setPending(await relay.pending());
25
+ }
26
+
27
+ async function run(label: string, action: () => Promise<void>) {
28
+ setStatus(label);
29
+ setLastError("none");
30
+ try {
31
+ await action();
32
+ await refreshPending();
33
+ setStatus("idle");
34
+ } catch (error) {
35
+ setLastError(error instanceof Error ? error.message : String(error));
36
+ setStatus("error");
37
+ }
38
+ }
39
+
40
+ async function drainUploads() {
41
+ await run("draining", () => relay.drainUploads());
42
+ }
43
+
44
+ async function receiveHostFrame(
45
+ deviceId: string,
46
+ frameBytes: Uint8Array,
47
+ writeAck: (ackBytes: Uint8Array) => Promise<void>
48
+ ) {
49
+ await relay.receiveFrame(deviceId, frameBytes, {
50
+ acknowledge: async ({ ackBytes }) => {
51
+ await writeAck(ackBytes);
52
+ setLastReceivedDeviceId(deviceId);
53
+ }
54
+ });
55
+ await refreshPending();
56
+ }
57
+
58
+ void receiveHostFrame;
59
+
60
+ return (
61
+ <SafeAreaView style={styles.root}>
62
+ <ScrollView contentContainerStyle={styles.content}>
63
+ <Text style={styles.title}>Honch Relay Example</Text>
64
+ <View style={styles.section}>
65
+ <Text>Status: {status}</Text>
66
+ <Text>Pending messages: {pending.length}</Text>
67
+ <Text>Last received device: {lastReceivedDeviceId}</Text>
68
+ <Text>Last error: {lastError}</Text>
69
+ </View>
70
+ <View style={styles.actions}>
71
+ <TouchableOpacity style={styles.button} onPress={drainUploads}>
72
+ <Text style={styles.buttonText}>Drain Uploads</Text>
73
+ </TouchableOpacity>
74
+ </View>
75
+ <View style={styles.section}>
76
+ <Text style={styles.heading}>Pending Messages</Text>
77
+ {pending.map((message) => (
78
+ <View key={`${message.deviceId}:${message.sequence}`} style={styles.messageRow}>
79
+ <Text>{message.deviceId}</Text>
80
+ <Text>
81
+ seq {message.sequence} / {message.body.byteLength} bytes
82
+ </Text>
83
+ </View>
84
+ ))}
85
+ </View>
86
+ </ScrollView>
87
+ </SafeAreaView>
88
+ );
89
+ }
90
+
91
+ const styles = StyleSheet.create({
92
+ root: {
93
+ flex: 1
94
+ },
95
+ content: {
96
+ gap: 16,
97
+ padding: 20
98
+ },
99
+ title: {
100
+ fontSize: 24,
101
+ fontWeight: "700"
102
+ },
103
+ section: {
104
+ gap: 8
105
+ },
106
+ heading: {
107
+ fontSize: 16,
108
+ fontWeight: "700"
109
+ },
110
+ actions: {
111
+ flexDirection: "row",
112
+ flexWrap: "wrap",
113
+ gap: 8
114
+ },
115
+ button: {
116
+ backgroundColor: "#111827",
117
+ borderRadius: 6,
118
+ paddingHorizontal: 12,
119
+ paddingVertical: 10
120
+ },
121
+ buttonText: {
122
+ color: "#ffffff",
123
+ fontWeight: "600"
124
+ },
125
+ messageRow: {
126
+ borderColor: "#d1d5db",
127
+ borderRadius: 6,
128
+ borderWidth: 1,
129
+ gap: 4,
130
+ padding: 12
131
+ }
132
+ });
@@ -0,0 +1,37 @@
1
+ # Honch React Native Relay Example
2
+
3
+ This directory contains a host-app example shape for validating the preview
4
+ React Native Relay package inside a consuming mobile app.
5
+
6
+ `relay.ts` wires the package to `NativeModules.HonchReactNativeRelay`, stores
7
+ relay queue state in MMKV, and exports the app-owned relay singleton used by
8
+ both the foreground UI and Android headless upload task. `App.tsx` shows the
9
+ host-owned handoff shape: the host app's BLE stack calls `relay.receiveFrame`
10
+ with notification bytes and writes the returned ACK bytes itself.
11
+ `index.ts` registers the `HonchRelayUpload` headless task.
12
+
13
+ The example app should prove:
14
+
15
+ - Host-owned BLE scan, discovery, connect, notify subscription, and ACK writes.
16
+ - Relay chunk receipt and CRC validation.
17
+ - Durable mobile-side message assembly.
18
+ - Manual and scheduled upload draining.
19
+ - iOS foreground upload draining from the host app lifecycle.
20
+ - Android headless `HonchRelayUpload` task registration for scheduled drains.
21
+ - Capture ingestion verification using a unique event name.
22
+
23
+ Required app configuration:
24
+
25
+ - Capture endpoint URL.
26
+ - Honch project key or relay-scoped token.
27
+ - Relay ID.
28
+ - Bluetooth usage strings, background modes, and runtime permissions required by
29
+ the host app's own BLE implementation.
30
+ - The relay package does not merge Android BLE, location, or notification
31
+ permissions; add host-app permissions only for host-owned BLE or separate
32
+ host features.
33
+
34
+ The package directory is not a complete React Native host app. To run this
35
+ example, copy the app into a generated React Native project or add platform
36
+ host files (`ios/`, `android/`, `Podfile`, Gradle wrapper), then install this
37
+ package through the host app.
@@ -0,0 +1,10 @@
1
+ import { AppRegistry } from "react-native";
2
+
3
+ import App from "./App";
4
+ import { relay } from "./relay";
5
+
6
+ AppRegistry.registerComponent("HonchRelayExample", () => App);
7
+
8
+ AppRegistry.registerHeadlessTask("HonchRelayUpload", () => async () => {
9
+ await relay.drainUploads();
10
+ });
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "honch-react-native-relay-example",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "scripts": {
6
+ "android": "react-native run-android",
7
+ "ios": "react-native run-ios",
8
+ "start": "react-native start"
9
+ },
10
+ "dependencies": {
11
+ "@honch/react-native-relay": "file:..",
12
+ "react": "latest",
13
+ "react-native": "latest",
14
+ "react-native-mmkv": "^4.3.1"
15
+ },
16
+ "devDependencies": {
17
+ "@react-native-community/cli": "latest",
18
+ "typescript": "latest"
19
+ }
20
+ }
@@ -0,0 +1,27 @@
1
+ import { NativeModules } from "react-native";
2
+ import { createMMKV } from "react-native-mmkv";
3
+ import {
4
+ createMmkvRelayStore,
5
+ createMobileRelay,
6
+ createRelayNativeBindings,
7
+ type StoredRelayMessage
8
+ } from "@honch/react-native-relay";
9
+
10
+ const captureConfig = {
11
+ endpointUrl: "http://127.0.0.1:8001",
12
+ projectKey: "test_key_123",
13
+ relayId: "mobile-relay-example",
14
+ relaySdkPlatform: "react-native",
15
+ relaySdkVersion: "0.1.0",
16
+ streamId: (message: StoredRelayMessage) => `relay-${message.deviceId}`,
17
+ messageId: (message: StoredRelayMessage) => Number(message.sequence)
18
+ };
19
+
20
+ const nativeModule = NativeModules.HonchReactNativeRelay;
21
+ const bindings = createRelayNativeBindings(nativeModule);
22
+
23
+ export const relay = createMobileRelay({
24
+ durableStore: createMmkvRelayStore(createMMKV({ id: "honch-relay-example" })),
25
+ uploaderConfig: captureConfig,
26
+ schedulerNative: bindings.schedulerNative
27
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@honch/react-native-relay",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "react-native": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "scripts": {
9
+ "test": "vitest run",
10
+ "typecheck": "tsc --noEmit",
11
+ "e2e:capture": "bun e2e/relay-capture-e2e.ts",
12
+ "verify:android:native": "scripts/verify-android-native.sh",
13
+ "verify:ios:native": "scripts/verify-ios-syntax.sh"
14
+ },
15
+ "peerDependencies": {
16
+ "react-native": ">=0.72",
17
+ "react-native-mmkv": ">=2 <5",
18
+ "react-native-nitro-modules": "^0.35.7"
19
+ },
20
+ "peerDependenciesMeta": {
21
+ "react-native-mmkv": {
22
+ "optional": true
23
+ },
24
+ "react-native-nitro-modules": {
25
+ "optional": true
26
+ }
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^25.9.1",
30
+ "typescript": "^5.0.0",
31
+ "vitest": "^1.0.0"
32
+ }
33
+ }
@@ -0,0 +1,11 @@
1
+ export default {
2
+ dependency: {
3
+ platforms: {
4
+ android: {
5
+ sourceDir: "./android",
6
+ packageImportPath: "import io.honch.reactnativerelay.HonchReactNativeRelayPackage;",
7
+ packageInstance: "new HonchReactNativeRelayPackage()"
8
+ }
9
+ }
10
+ }
11
+ };
package/src/drain.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { nextBackoffDelayMs } from "./retry";
2
+ import type { RelayQueue, StoredRelayMessage } from "./relayQueue";
3
+ import type { RelayUploadOutcome } from "./uploader";
4
+
5
+ export type DrainRelayQueueOptions = {
6
+ queue: RelayQueue;
7
+ upload(message: StoredRelayMessage): Promise<RelayUploadOutcome>;
8
+ now?: () => number;
9
+ random?: () => number;
10
+ };
11
+
12
+ export async function drainRelayQueue(options: DrainRelayQueueOptions): Promise<void> {
13
+ const now = options.now ?? Date.now;
14
+ const messages = await options.queue.pending();
15
+ for (const message of messages) {
16
+ const currentTimeMs = now();
17
+ if (message.nextAttemptAtMs !== undefined && message.nextAttemptAtMs > currentTimeMs) {
18
+ continue;
19
+ }
20
+ const outcome = await options.upload(message);
21
+ if (outcome.action === "consume") {
22
+ await options.queue.markUploaded(message.deviceId, message.sequence);
23
+ continue;
24
+ }
25
+ if (outcome.action === "drop") {
26
+ await options.queue.markDropped(message.deviceId, message.sequence);
27
+ continue;
28
+ }
29
+ const attempt = (message.retryAttempt ?? 0) + 1;
30
+ const delayMs = outcome.retryAfterMs ?? nextBackoffDelayMs(message.retryAttempt ?? 0, options.random);
31
+ await options.queue.markRetry(message.deviceId, message.sequence, {
32
+ attempt,
33
+ nextAttemptAtMs: currentTimeMs + delayMs
34
+ });
35
+ }
36
+ }