@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 +155 -0
- package/android/build.gradle +30 -0
- package/android/gradle.properties +1 -0
- package/android/settings.gradle +12 -0
- package/android/src/main/AndroidManifest.xml +7 -0
- package/android/src/main/java/io/honch/reactnativerelay/HonchReactNativeRelayModule.java +67 -0
- package/android/src/main/java/io/honch/reactnativerelay/HonchReactNativeRelayPackage.java +28 -0
- package/android/src/main/java/io/honch/reactnativerelay/HonchRelayUploadTaskService.java +24 -0
- package/android/src/main/java/io/honch/reactnativerelay/HonchRelayUploadWorker.java +34 -0
- package/example/App.tsx +132 -0
- package/example/README.md +37 -0
- package/example/index.ts +10 -0
- package/example/package.json +20 -0
- package/example/relay.ts +27 -0
- package/package.json +33 -0
- package/react-native.config.js +11 -0
- package/src/drain.ts +36 -0
- package/src/durableStore.ts +122 -0
- package/src/frame.ts +94 -0
- package/src/index.ts +28 -0
- package/src/mobileRelay.ts +73 -0
- package/src/nativeModule.ts +29 -0
- package/src/relayFrameReceiver.ts +67 -0
- package/src/relayQueue.ts +282 -0
- package/src/retry.ts +13 -0
- package/src/scheduler.ts +20 -0
- package/src/storage/jsonFileStore.ts +206 -0
- package/src/storage/mmkvStore.ts +622 -0
- package/src/uploader.ts +165 -0
- package/src/wireV2.ts +198 -0
- package/tsconfig.json +11 -0
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,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
|
+
}
|
package/example/App.tsx
ADDED
|
@@ -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.
|
package/example/index.ts
ADDED
|
@@ -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
|
+
}
|
package/example/relay.ts
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|