@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/uploader.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { StoredRelayMessage } from "./relayQueue";
|
|
2
|
+
import { buildSingleWireV2Frame, buildWireV2Frames, MAX_WIRE_V2_FRAME_SIZE } from "./wireV2";
|
|
3
|
+
|
|
4
|
+
export type RelayUploaderConfig = {
|
|
5
|
+
endpointUrl: string;
|
|
6
|
+
projectKey: string;
|
|
7
|
+
relayId: string;
|
|
8
|
+
relaySdkPlatform: string;
|
|
9
|
+
relaySdkVersion: string;
|
|
10
|
+
streamId(message: StoredRelayMessage): string;
|
|
11
|
+
messageId(message: StoredRelayMessage): number;
|
|
12
|
+
// Max wire-v2 frame size for re-chunking; defaults to MAX_WIRE_V2_FRAME_SIZE.
|
|
13
|
+
maxFrameSize?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type RelayUploadOutcome =
|
|
17
|
+
| { action: "consume"; status: number }
|
|
18
|
+
| { action: "drop"; status?: number; error?: unknown }
|
|
19
|
+
| { action: "retry"; status?: number; retryAfterMs?: number; error?: unknown };
|
|
20
|
+
|
|
21
|
+
export function buildRelayUploadBuffer(
|
|
22
|
+
config: Pick<RelayUploaderConfig, "messageId">,
|
|
23
|
+
message: StoredRelayMessage
|
|
24
|
+
): ArrayBuffer {
|
|
25
|
+
const frame = buildSingleWireV2Frame({
|
|
26
|
+
messageId: config.messageId(message),
|
|
27
|
+
payload: message.body
|
|
28
|
+
});
|
|
29
|
+
const body = new ArrayBuffer(frame.byteLength);
|
|
30
|
+
new Uint8Array(body).set(frame);
|
|
31
|
+
return body;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function uploadRelayMessage(
|
|
35
|
+
config: RelayUploaderConfig,
|
|
36
|
+
message: StoredRelayMessage
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const outcome = await uploadRelayMessageOutcome(config, message);
|
|
39
|
+
if (outcome.action === "consume") {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (outcome.status !== undefined) {
|
|
43
|
+
throw new Error(`relay upload failed: ${outcome.status}`);
|
|
44
|
+
}
|
|
45
|
+
throw new Error("relay upload failed: network");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function uploadRelayMessageOutcome(
|
|
49
|
+
config: RelayUploaderConfig,
|
|
50
|
+
message: StoredRelayMessage
|
|
51
|
+
): Promise<RelayUploadOutcome> {
|
|
52
|
+
// Header values flow straight into the HTTP request. Reject control characters
|
|
53
|
+
// (CR/LF/NUL) in the project key and the stream id (derived from the device
|
|
54
|
+
// id) so a crafted value cannot inject headers -- and so an illegal value
|
|
55
|
+
// can't make fetch throw and wedge the message in an endless retry loop.
|
|
56
|
+
const streamId = config.streamId(message);
|
|
57
|
+
if (!isSafeHeaderValue(config.projectKey) || !isSafeHeaderValue(streamId)) {
|
|
58
|
+
return { action: "drop", error: new Error("relay header value contains control characters") };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Re-chunk the (possibly oversized) reassembled body into wire-v2 frames and
|
|
62
|
+
// POST them in order: every non-final frame must return 202 (stored, send
|
|
63
|
+
// next), and the final frame returns 204 (complete). Any error mid-sequence
|
|
64
|
+
// ends the attempt; the whole message is retried from the first frame (which
|
|
65
|
+
// also satisfies 409 "retry from offset 0").
|
|
66
|
+
let frames: Uint8Array[];
|
|
67
|
+
try {
|
|
68
|
+
frames = buildWireV2Frames(
|
|
69
|
+
config.messageId(message),
|
|
70
|
+
message.body,
|
|
71
|
+
config.maxFrameSize ?? MAX_WIRE_V2_FRAME_SIZE
|
|
72
|
+
);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// An un-encodable message id (e.g. beyond the wire-v2 safe-integer range)
|
|
75
|
+
// can never succeed on retry, so drop this one message instead of letting
|
|
76
|
+
// the throw propagate and stall the entire drain.
|
|
77
|
+
return { action: "drop", error };
|
|
78
|
+
}
|
|
79
|
+
const url = `${config.endpointUrl.replace(/\/$/, "")}/capture`;
|
|
80
|
+
const headers = {
|
|
81
|
+
"Content-Type": "application/vnd.honch.chunk",
|
|
82
|
+
"X-Honch-Project-Key": config.projectKey,
|
|
83
|
+
"X-Honch-Stream-Id": streamId,
|
|
84
|
+
"X-Honch-Relay-Id": config.relayId,
|
|
85
|
+
"X-Honch-Relay-SDK-Platform": config.relaySdkPlatform,
|
|
86
|
+
"X-Honch-Relay-SDK-Version": config.relaySdkVersion
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
for (let index = 0; index < frames.length; index += 1) {
|
|
90
|
+
const isFinal = index === frames.length - 1;
|
|
91
|
+
const body = new ArrayBuffer(frames[index].byteLength);
|
|
92
|
+
new Uint8Array(body).set(frames[index]);
|
|
93
|
+
|
|
94
|
+
let response: Response;
|
|
95
|
+
try {
|
|
96
|
+
response = await fetch(url, { method: "POST", headers, body });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return { action: "retry", error };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const outcome = classifyFrameResponse(response, isFinal);
|
|
102
|
+
if (outcome !== "continue") {
|
|
103
|
+
return outcome;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Unreachable: the final frame always yields a terminal outcome.
|
|
108
|
+
return { action: "retry" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function classifyFrameResponse(response: Response, isFinal: boolean): RelayUploadOutcome | "continue" {
|
|
112
|
+
if (isFinal) {
|
|
113
|
+
if (response.status === 204) {
|
|
114
|
+
return { action: "consume", status: response.status };
|
|
115
|
+
}
|
|
116
|
+
} else if (response.status === 202) {
|
|
117
|
+
return "continue";
|
|
118
|
+
}
|
|
119
|
+
// Permanent rejections (matching the C SDK status mapping): malformed (400),
|
|
120
|
+
// bad key (401), not found (404), payload too large (413), unsupported content
|
|
121
|
+
// type (415), semantic validation failure (422). 413 is permanent: the relay
|
|
122
|
+
// already re-chunks to a fixed frame size, so retrying the identical bytes can
|
|
123
|
+
// never clear it -- an oversized payload is dropped and logged like the C SDK.
|
|
124
|
+
if (
|
|
125
|
+
response.status === 400 ||
|
|
126
|
+
response.status === 401 ||
|
|
127
|
+
response.status === 404 ||
|
|
128
|
+
response.status === 413 ||
|
|
129
|
+
response.status === 415 ||
|
|
130
|
+
response.status === 422
|
|
131
|
+
) {
|
|
132
|
+
return { action: "drop", status: response.status };
|
|
133
|
+
}
|
|
134
|
+
// Everything else -- 409 (retry from offset 0), 429, 5xx, and any
|
|
135
|
+
// out-of-sequence 202/204 -- retries the whole message.
|
|
136
|
+
return {
|
|
137
|
+
action: "retry",
|
|
138
|
+
status: response.status,
|
|
139
|
+
retryAfterMs: parseRetryAfterMs(response.headers.get("Retry-After"))
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isSafeHeaderValue(value: string): boolean {
|
|
144
|
+
// HTTP header values must not carry control characters; CR/LF in particular
|
|
145
|
+
// would allow header injection, and most fetch implementations throw on them.
|
|
146
|
+
return typeof value === "string" && !/[\x00-\x1f\x7f]/.test(value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseRetryAfterMs(value: string | null): number | undefined {
|
|
150
|
+
if (value === null) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const seconds = Number(value);
|
|
155
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
156
|
+
return Math.round(seconds * 1000);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const retryAt = Date.parse(value);
|
|
160
|
+
if (!Number.isFinite(retryAt)) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return Math.max(0, retryAt - Date.now());
|
|
165
|
+
}
|
package/src/wireV2.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
export type WireV2FrameOptions = {
|
|
2
|
+
messageId: number;
|
|
3
|
+
payload: Uint8Array;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const VERSION_CODE = 2;
|
|
7
|
+
const SOURCE_TYPE_EVENTS = 0;
|
|
8
|
+
|
|
9
|
+
export function buildSingleWireV2Frame(options: WireV2FrameOptions): Uint8Array {
|
|
10
|
+
const messageId = encodeUvarint(options.messageId);
|
|
11
|
+
const crc = crc16CcittFalse(options.payload);
|
|
12
|
+
const header = (SOURCE_TYPE_EVENTS << 2) | VERSION_CODE;
|
|
13
|
+
const frame = new Uint8Array(1 + messageId.length + options.payload.length + 2);
|
|
14
|
+
let offset = 0;
|
|
15
|
+
frame[offset++] = header;
|
|
16
|
+
frame.set(messageId, offset);
|
|
17
|
+
offset += messageId.length;
|
|
18
|
+
frame.set(options.payload, offset);
|
|
19
|
+
offset += options.payload.length;
|
|
20
|
+
frame[offset++] = crc & 0xff;
|
|
21
|
+
frame[offset] = (crc >> 8) & 0xff;
|
|
22
|
+
return frame;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function encodeUvarint(value: number): Uint8Array {
|
|
26
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
27
|
+
throw new Error("wire-v2 message id must be a non-negative safe integer");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const bytes: number[] = [];
|
|
31
|
+
let remaining = value;
|
|
32
|
+
do {
|
|
33
|
+
let byte = remaining & 0x7f;
|
|
34
|
+
remaining = Math.floor(remaining / 128);
|
|
35
|
+
if (remaining !== 0) {
|
|
36
|
+
byte |= 0x80;
|
|
37
|
+
}
|
|
38
|
+
bytes.push(byte);
|
|
39
|
+
} while (remaining !== 0);
|
|
40
|
+
|
|
41
|
+
return new Uint8Array(bytes);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const HEADER_CONTINUATION = 0x20;
|
|
45
|
+
const HEADER_MORE = 0x40;
|
|
46
|
+
const FINAL_CRC_SIZE = 2;
|
|
47
|
+
|
|
48
|
+
// Matches the shipping C SDK frame size (HONCH_WIRE_V2_MAX_FRAME_BYTES), which
|
|
49
|
+
// Capture is known to accept, so re-chunked relay frames are guaranteed valid.
|
|
50
|
+
export const MAX_WIRE_V2_FRAME_SIZE = 4096;
|
|
51
|
+
|
|
52
|
+
export function uvarintSize(value: number): number {
|
|
53
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
54
|
+
throw new Error("wire-v2 uvarint must be a non-negative safe integer");
|
|
55
|
+
}
|
|
56
|
+
let size = 1;
|
|
57
|
+
let remaining = Math.floor(value / 128);
|
|
58
|
+
while (remaining !== 0) {
|
|
59
|
+
size += 1;
|
|
60
|
+
remaining = Math.floor(remaining / 128);
|
|
61
|
+
}
|
|
62
|
+
return size;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function frameOverhead(
|
|
66
|
+
messageId: number,
|
|
67
|
+
offset: number,
|
|
68
|
+
total: number,
|
|
69
|
+
continuation: boolean,
|
|
70
|
+
more: boolean
|
|
71
|
+
): number {
|
|
72
|
+
let overhead = 1 + uvarintSize(messageId);
|
|
73
|
+
if (continuation) {
|
|
74
|
+
overhead += uvarintSize(offset);
|
|
75
|
+
} else if (more) {
|
|
76
|
+
overhead += uvarintSize(total);
|
|
77
|
+
}
|
|
78
|
+
if (!more) {
|
|
79
|
+
overhead += FINAL_CRC_SIZE;
|
|
80
|
+
}
|
|
81
|
+
return overhead;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function encodeWireV2Frame(
|
|
85
|
+
messageId: number,
|
|
86
|
+
payload: Uint8Array,
|
|
87
|
+
offset: number,
|
|
88
|
+
total: number,
|
|
89
|
+
continuation: boolean,
|
|
90
|
+
more: boolean,
|
|
91
|
+
crcWholeMessage: number
|
|
92
|
+
): Uint8Array {
|
|
93
|
+
const messageIdBytes = encodeUvarint(messageId);
|
|
94
|
+
const positionBytes = continuation
|
|
95
|
+
? encodeUvarint(offset)
|
|
96
|
+
: more
|
|
97
|
+
? encodeUvarint(total)
|
|
98
|
+
: new Uint8Array(0);
|
|
99
|
+
|
|
100
|
+
let header = (SOURCE_TYPE_EVENTS << 2) | VERSION_CODE;
|
|
101
|
+
if (continuation) header |= HEADER_CONTINUATION;
|
|
102
|
+
if (more) header |= HEADER_MORE;
|
|
103
|
+
|
|
104
|
+
const size =
|
|
105
|
+
1 + messageIdBytes.length + positionBytes.length + payload.length + (more ? 0 : FINAL_CRC_SIZE);
|
|
106
|
+
const frame = new Uint8Array(size);
|
|
107
|
+
let p = 0;
|
|
108
|
+
frame[p++] = header;
|
|
109
|
+
frame.set(messageIdBytes, p);
|
|
110
|
+
p += messageIdBytes.length;
|
|
111
|
+
frame.set(positionBytes, p);
|
|
112
|
+
p += positionBytes.length;
|
|
113
|
+
frame.set(payload, p);
|
|
114
|
+
p += payload.length;
|
|
115
|
+
if (!more) {
|
|
116
|
+
// CRC is over the WHOLE reassembled message, not just this final chunk.
|
|
117
|
+
frame[p++] = crcWholeMessage & 0xff;
|
|
118
|
+
frame[p] = (crcWholeMessage >> 8) & 0xff;
|
|
119
|
+
}
|
|
120
|
+
return frame;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Split a complete compact message into wire-v2 frames each <= maxFrameSize,
|
|
124
|
+
// so an oversized relayed body is delivered as a multi-frame sequence rather
|
|
125
|
+
// than a single over-limit frame. A payload that already fits returns one frame
|
|
126
|
+
// (byte-identical to buildSingleWireV2Frame). Faithful port of the C chunker.
|
|
127
|
+
export function buildWireV2Frames(
|
|
128
|
+
messageId: number,
|
|
129
|
+
payload: Uint8Array,
|
|
130
|
+
maxFrameSize: number = MAX_WIRE_V2_FRAME_SIZE
|
|
131
|
+
): Uint8Array[] {
|
|
132
|
+
if (!Number.isSafeInteger(maxFrameSize) || maxFrameSize < 8) {
|
|
133
|
+
throw new Error("wire-v2 max frame size too small");
|
|
134
|
+
}
|
|
135
|
+
const total = payload.length;
|
|
136
|
+
const crcWhole = crc16CcittFalse(payload);
|
|
137
|
+
|
|
138
|
+
if (total === 0) {
|
|
139
|
+
return [encodeWireV2Frame(messageId, payload, 0, 0, false, false, crcWhole)];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const frames: Uint8Array[] = [];
|
|
143
|
+
let offset = 0;
|
|
144
|
+
do {
|
|
145
|
+
const continuation = offset !== 0;
|
|
146
|
+
const remaining = total - offset;
|
|
147
|
+
|
|
148
|
+
const finalOverhead = frameOverhead(messageId, offset, total, continuation, false);
|
|
149
|
+
if (finalOverhead >= maxFrameSize) {
|
|
150
|
+
throw new Error("wire-v2 frame overhead exceeds max frame size");
|
|
151
|
+
}
|
|
152
|
+
const finalCapacity = maxFrameSize - finalOverhead;
|
|
153
|
+
|
|
154
|
+
const more = remaining > finalCapacity;
|
|
155
|
+
let payloadCapacity = finalCapacity;
|
|
156
|
+
if (more) {
|
|
157
|
+
const moreOverhead = frameOverhead(messageId, offset, total, continuation, true);
|
|
158
|
+
if (moreOverhead >= maxFrameSize) {
|
|
159
|
+
throw new Error("wire-v2 frame overhead exceeds max frame size");
|
|
160
|
+
}
|
|
161
|
+
payloadCapacity = maxFrameSize - moreOverhead;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let payloadSize = Math.min(remaining, payloadCapacity);
|
|
165
|
+
if (more && payloadSize >= remaining) {
|
|
166
|
+
payloadSize = remaining - finalCapacity;
|
|
167
|
+
}
|
|
168
|
+
if (payloadSize <= 0) {
|
|
169
|
+
throw new Error("wire-v2 produced a non-positive frame payload");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
frames.push(
|
|
173
|
+
encodeWireV2Frame(
|
|
174
|
+
messageId,
|
|
175
|
+
payload.subarray(offset, offset + payloadSize),
|
|
176
|
+
offset,
|
|
177
|
+
total,
|
|
178
|
+
continuation,
|
|
179
|
+
more,
|
|
180
|
+
crcWhole
|
|
181
|
+
)
|
|
182
|
+
);
|
|
183
|
+
offset += payloadSize;
|
|
184
|
+
} while (offset < total);
|
|
185
|
+
|
|
186
|
+
return frames;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function crc16CcittFalse(bytes: Uint8Array): number {
|
|
190
|
+
let crc = 0xffff;
|
|
191
|
+
for (const byte of bytes) {
|
|
192
|
+
crc ^= byte << 8;
|
|
193
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
194
|
+
crc = (crc & 0x8000) !== 0 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return crc;
|
|
198
|
+
}
|