@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/src/retry.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const INITIAL_BACKOFF_MS = 1000;
|
|
2
|
+
const MAX_BACKOFF_MS = 300000;
|
|
3
|
+
const JITTER_RATIO = 0.25;
|
|
4
|
+
|
|
5
|
+
export function nextBackoffDelayMs(
|
|
6
|
+
attempt: number,
|
|
7
|
+
random: () => number = Math.random
|
|
8
|
+
): number {
|
|
9
|
+
const safeAttempt = Math.max(0, attempt);
|
|
10
|
+
const base = Math.min(INITIAL_BACKOFF_MS * 2 ** safeAttempt, MAX_BACKOFF_MS);
|
|
11
|
+
const jitter = 1 - JITTER_RATIO + random() * JITTER_RATIO * 2;
|
|
12
|
+
return Math.min(Math.round(base * jitter), MAX_BACKOFF_MS);
|
|
13
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface RelayUploadSchedulerNative {
|
|
2
|
+
scheduleUpload(delayMs: number): Promise<void>;
|
|
3
|
+
cancelUpload(): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type RelayUploadSchedulerOptions = {
|
|
7
|
+
native: RelayUploadSchedulerNative;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function createRelayUploadScheduler(options: RelayUploadSchedulerOptions) {
|
|
11
|
+
return {
|
|
12
|
+
schedule(delayMs: number): Promise<void> {
|
|
13
|
+
return options.native.scheduleUpload(delayMs);
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
cancel(): Promise<void> {
|
|
17
|
+
return options.native.cancelUpload();
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { DurableRelayChunk, RelayDurableStore } from "../durableStore";
|
|
5
|
+
import type { RelayRetryState } from "../relayQueue";
|
|
6
|
+
import type { StoredRelayMessage } from "../relayQueue";
|
|
7
|
+
|
|
8
|
+
type JsonRelayStoreState = {
|
|
9
|
+
chunks: JsonRelayChunk[];
|
|
10
|
+
messages: JsonRelayMessage[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type JsonRelayChunk = {
|
|
14
|
+
deviceId: string;
|
|
15
|
+
sourceType: number;
|
|
16
|
+
sequence: string;
|
|
17
|
+
offset: number;
|
|
18
|
+
frameBytes: number[];
|
|
19
|
+
payload: number[];
|
|
20
|
+
finalEnd?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type JsonRelayMessage = {
|
|
24
|
+
deviceId: string;
|
|
25
|
+
sourceType: number;
|
|
26
|
+
sequence: string;
|
|
27
|
+
body: number[];
|
|
28
|
+
retryAttempt?: number;
|
|
29
|
+
nextAttemptAtMs?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const EMPTY_STATE: JsonRelayStoreState = {
|
|
33
|
+
chunks: [],
|
|
34
|
+
messages: []
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Defaults match the MMKV store so retention is consistent across relay stores.
|
|
38
|
+
const DEFAULT_MAX_CHUNKS = 4096;
|
|
39
|
+
const DEFAULT_MAX_COMPLETE_MESSAGES = 1024;
|
|
40
|
+
|
|
41
|
+
export type JsonFileRelayStoreOptions = {
|
|
42
|
+
// Bound the on-disk store so it cannot grow without limit when uploads stall;
|
|
43
|
+
// when exceeded, the oldest entries are dropped (drop-oldest). No time TTL.
|
|
44
|
+
maxChunks?: number;
|
|
45
|
+
maxCompleteMessages?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function createJsonFileRelayStore(
|
|
49
|
+
filePath: string,
|
|
50
|
+
options: JsonFileRelayStoreOptions = {}
|
|
51
|
+
): RelayDurableStore {
|
|
52
|
+
return new JsonFileRelayStore(
|
|
53
|
+
filePath,
|
|
54
|
+
options.maxChunks ?? DEFAULT_MAX_CHUNKS,
|
|
55
|
+
options.maxCompleteMessages ?? DEFAULT_MAX_COMPLETE_MESSAGES
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class JsonFileRelayStore implements RelayDurableStore {
|
|
60
|
+
constructor(
|
|
61
|
+
private readonly filePath: string,
|
|
62
|
+
private readonly maxChunks: number,
|
|
63
|
+
private readonly maxCompleteMessages: number
|
|
64
|
+
) {}
|
|
65
|
+
|
|
66
|
+
async putChunk(chunk: DurableRelayChunk): Promise<void> {
|
|
67
|
+
const state = await this.readState();
|
|
68
|
+
const serialized = serializeChunk(chunk);
|
|
69
|
+
const index = state.chunks.findIndex(
|
|
70
|
+
(entry) =>
|
|
71
|
+
entry.deviceId === chunk.deviceId &&
|
|
72
|
+
entry.sequence === chunk.sequence &&
|
|
73
|
+
entry.offset === chunk.offset
|
|
74
|
+
);
|
|
75
|
+
if (index >= 0) {
|
|
76
|
+
state.chunks[index] = serialized;
|
|
77
|
+
} else {
|
|
78
|
+
state.chunks.push(serialized);
|
|
79
|
+
while (state.chunks.length > this.maxChunks) {
|
|
80
|
+
state.chunks.shift();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await this.writeState(state);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async chunks(deviceId: string, sequence: string): Promise<DurableRelayChunk[]> {
|
|
87
|
+
const state = await this.readState();
|
|
88
|
+
return state.chunks
|
|
89
|
+
.filter((chunk) => chunk.deviceId === deviceId && chunk.sequence === sequence)
|
|
90
|
+
.map(deserializeChunk);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async putCompleteMessage(message: StoredRelayMessage): Promise<void> {
|
|
94
|
+
const state = await this.readState();
|
|
95
|
+
const serialized = serializeMessage(message);
|
|
96
|
+
const index = state.messages.findIndex(
|
|
97
|
+
(entry) => entry.deviceId === message.deviceId && entry.sequence === message.sequence
|
|
98
|
+
);
|
|
99
|
+
if (index >= 0) {
|
|
100
|
+
state.messages[index] = serialized;
|
|
101
|
+
} else {
|
|
102
|
+
state.messages.push(serialized);
|
|
103
|
+
while (state.messages.length > this.maxCompleteMessages) {
|
|
104
|
+
state.messages.shift();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
await this.writeState(state);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async completeMessages(): Promise<StoredRelayMessage[]> {
|
|
111
|
+
const state = await this.readState();
|
|
112
|
+
return state.messages.map(deserializeMessage);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async markRetry(deviceId: string, sequence: string, retry: RelayRetryState): Promise<void> {
|
|
116
|
+
const state = await this.readState();
|
|
117
|
+
const message = state.messages.find(
|
|
118
|
+
(entry) => entry.deviceId === deviceId && entry.sequence === sequence
|
|
119
|
+
);
|
|
120
|
+
if (message !== undefined) {
|
|
121
|
+
message.retryAttempt = retry.attempt;
|
|
122
|
+
message.nextAttemptAtMs = retry.nextAttemptAtMs;
|
|
123
|
+
await this.writeState(state);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async deleteMessage(deviceId: string, sequence: string): Promise<void> {
|
|
128
|
+
const state = await this.readState();
|
|
129
|
+
state.messages = state.messages.filter(
|
|
130
|
+
(message) => message.deviceId !== deviceId || message.sequence !== sequence
|
|
131
|
+
);
|
|
132
|
+
state.chunks = state.chunks.filter(
|
|
133
|
+
(chunk) => chunk.deviceId !== deviceId || chunk.sequence !== sequence
|
|
134
|
+
);
|
|
135
|
+
await this.writeState(state);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async readState(): Promise<JsonRelayStoreState> {
|
|
139
|
+
try {
|
|
140
|
+
const raw = await readFile(this.filePath, "utf8");
|
|
141
|
+
const parsed = JSON.parse(raw) as JsonRelayStoreState;
|
|
142
|
+
return {
|
|
143
|
+
chunks: parsed.chunks ?? [],
|
|
144
|
+
messages: parsed.messages ?? []
|
|
145
|
+
};
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
148
|
+
return { chunks: [], messages: [] };
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async writeState(state: JsonRelayStoreState): Promise<void> {
|
|
155
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
156
|
+
const tempPath = `${this.filePath}.tmp`;
|
|
157
|
+
await writeFile(tempPath, JSON.stringify(state));
|
|
158
|
+
await rename(tempPath, this.filePath);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function serializeChunk(chunk: DurableRelayChunk): JsonRelayChunk {
|
|
163
|
+
return {
|
|
164
|
+
deviceId: chunk.deviceId,
|
|
165
|
+
sourceType: chunk.sourceType,
|
|
166
|
+
sequence: chunk.sequence,
|
|
167
|
+
offset: chunk.offset,
|
|
168
|
+
frameBytes: Array.from(chunk.frameBytes),
|
|
169
|
+
payload: Array.from(chunk.payload),
|
|
170
|
+
finalEnd: chunk.finalEnd
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function deserializeChunk(chunk: JsonRelayChunk): DurableRelayChunk {
|
|
175
|
+
return {
|
|
176
|
+
deviceId: chunk.deviceId,
|
|
177
|
+
sourceType: chunk.sourceType,
|
|
178
|
+
sequence: chunk.sequence,
|
|
179
|
+
offset: chunk.offset,
|
|
180
|
+
frameBytes: new Uint8Array(chunk.frameBytes),
|
|
181
|
+
payload: new Uint8Array(chunk.payload),
|
|
182
|
+
finalEnd: chunk.finalEnd
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function serializeMessage(message: StoredRelayMessage): JsonRelayMessage {
|
|
187
|
+
return {
|
|
188
|
+
deviceId: message.deviceId,
|
|
189
|
+
sourceType: message.sourceType,
|
|
190
|
+
sequence: message.sequence,
|
|
191
|
+
body: Array.from(message.body),
|
|
192
|
+
retryAttempt: message.retryAttempt,
|
|
193
|
+
nextAttemptAtMs: message.nextAttemptAtMs
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function deserializeMessage(message: JsonRelayMessage): StoredRelayMessage {
|
|
198
|
+
return {
|
|
199
|
+
deviceId: message.deviceId,
|
|
200
|
+
sourceType: message.sourceType,
|
|
201
|
+
sequence: message.sequence,
|
|
202
|
+
body: new Uint8Array(message.body),
|
|
203
|
+
retryAttempt: message.retryAttempt,
|
|
204
|
+
nextAttemptAtMs: message.nextAttemptAtMs
|
|
205
|
+
};
|
|
206
|
+
}
|