@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.
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "types": ["vitest/globals"]
9
+ },
10
+ "include": ["src/**/*.ts", "test/**/*.ts"]
11
+ }