@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,122 @@
1
+ import type { StoredRelayMessage } from "./relayQueue";
2
+ import type { RelayRetryState } from "./relayQueue";
3
+
4
+ export type DurableRelayChunk = {
5
+ deviceId: string;
6
+ sourceType: number;
7
+ sequence: string;
8
+ offset: number;
9
+ frameBytes: Uint8Array;
10
+ payload: Uint8Array;
11
+ finalEnd?: number;
12
+ };
13
+
14
+ export interface RelayDurableStore {
15
+ putChunk(chunk: DurableRelayChunk): Promise<void>;
16
+ chunks(deviceId: string, sequence: string): Promise<DurableRelayChunk[]>;
17
+ putCompleteMessage(message: StoredRelayMessage): Promise<void>;
18
+ completeMessages(): Promise<StoredRelayMessage[]>;
19
+ markRetry(deviceId: string, sequence: string, retry: RelayRetryState): Promise<void>;
20
+ deleteMessage(deviceId: string, sequence: string): Promise<void>;
21
+ }
22
+
23
+ export type MemoryDurableStoreOptions = {
24
+ // Upper bounds on retained complete messages and partial-reassembly chunks.
25
+ // When exceeded, the oldest entries are evicted (drop-oldest), matching the
26
+ // core SDK's bounded RAM-queue policy so the in-memory store cannot grow
27
+ // without bound when uploads stall. Defaults match the MMKV store.
28
+ maxCompleteMessages?: number;
29
+ maxChunks?: number;
30
+ };
31
+
32
+ const DEFAULT_MAX_CHUNKS = 4096;
33
+ const DEFAULT_MAX_COMPLETE_MESSAGES = 1024;
34
+
35
+ export function createMemoryDurableStore(options?: MemoryDurableStoreOptions): RelayDurableStore {
36
+ const maxMessages = options?.maxCompleteMessages ?? DEFAULT_MAX_COMPLETE_MESSAGES;
37
+ const maxChunks = options?.maxChunks ?? DEFAULT_MAX_CHUNKS;
38
+ const chunks: DurableRelayChunk[] = [];
39
+ const messages: StoredRelayMessage[] = [];
40
+
41
+ return {
42
+ async putChunk(chunk) {
43
+ const existing = chunks.find(
44
+ (entry) =>
45
+ entry.deviceId === chunk.deviceId &&
46
+ entry.sequence === chunk.sequence &&
47
+ entry.offset === chunk.offset
48
+ );
49
+ if (existing !== undefined) {
50
+ Object.assign(existing, cloneChunk(chunk));
51
+ return;
52
+ }
53
+ chunks.push(cloneChunk(chunk));
54
+ while (chunks.length > maxChunks) {
55
+ chunks.shift();
56
+ }
57
+ },
58
+
59
+ async chunks(deviceId, sequence) {
60
+ return chunks
61
+ .filter((chunk) => chunk.deviceId === deviceId && chunk.sequence === sequence)
62
+ .map(cloneChunk);
63
+ },
64
+
65
+ async putCompleteMessage(message) {
66
+ const existing = messages.find(
67
+ (entry) => entry.deviceId === message.deviceId && entry.sequence === message.sequence
68
+ );
69
+ if (existing !== undefined) {
70
+ existing.body = new Uint8Array(message.body);
71
+ existing.sourceType = message.sourceType;
72
+ return;
73
+ }
74
+ messages.push(cloneMessage(message));
75
+ while (messages.length > maxMessages) {
76
+ messages.shift();
77
+ }
78
+ },
79
+
80
+ async completeMessages() {
81
+ return messages.map(cloneMessage);
82
+ },
83
+
84
+ async markRetry(deviceId, sequence, retry) {
85
+ const message = messages.find(
86
+ (entry) => entry.deviceId === deviceId && entry.sequence === sequence
87
+ );
88
+ if (message !== undefined) {
89
+ message.retryAttempt = retry.attempt;
90
+ message.nextAttemptAtMs = retry.nextAttemptAtMs;
91
+ }
92
+ },
93
+
94
+ async deleteMessage(deviceId, sequence) {
95
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
96
+ if (messages[i].deviceId === deviceId && messages[i].sequence === sequence) {
97
+ messages.splice(i, 1);
98
+ }
99
+ }
100
+ for (let i = chunks.length - 1; i >= 0; i -= 1) {
101
+ if (chunks[i].deviceId === deviceId && chunks[i].sequence === sequence) {
102
+ chunks.splice(i, 1);
103
+ }
104
+ }
105
+ }
106
+ };
107
+ }
108
+
109
+ function cloneChunk(chunk: DurableRelayChunk): DurableRelayChunk {
110
+ return {
111
+ ...chunk,
112
+ frameBytes: new Uint8Array(chunk.frameBytes),
113
+ payload: new Uint8Array(chunk.payload)
114
+ };
115
+ }
116
+
117
+ function cloneMessage(message: StoredRelayMessage): StoredRelayMessage {
118
+ return {
119
+ ...message,
120
+ body: new Uint8Array(message.body)
121
+ };
122
+ }
package/src/frame.ts ADDED
@@ -0,0 +1,94 @@
1
+ export type RelayFrame = {
2
+ version: number;
3
+ sourceType: number;
4
+ first: boolean;
5
+ final: boolean;
6
+ sequence: bigint;
7
+ offset: number;
8
+ payload: Uint8Array;
9
+ };
10
+
11
+ const RELAY_FRAME_HEADER_SIZE = 20;
12
+ const RELAY_FRAME_CRC_OFFSET = 18;
13
+ const RELAY_FRAME_SOURCE_TYPE_EVENTS = 1;
14
+ const RELAY_FRAME_CRC_INITIAL = 0xffff;
15
+ const RELAY_FRAME_CRC_POLYNOMIAL = 0x1021;
16
+
17
+ export function decodeRelayFrame(bytes: Uint8Array): RelayFrame {
18
+ if (bytes.length < RELAY_FRAME_HEADER_SIZE) {
19
+ throw new Error("relay frame too short");
20
+ }
21
+
22
+ const version = bytes[0];
23
+ if (version !== 1) {
24
+ throw new Error("unsupported relay frame version");
25
+ }
26
+ if (bytes[1] !== RELAY_FRAME_SOURCE_TYPE_EVENTS) {
27
+ throw new Error("unsupported relay frame source type");
28
+ }
29
+ if (bytes[3] !== 0) {
30
+ throw new Error("relay frame reserved byte must be zero");
31
+ }
32
+ if ((bytes[2] & ~0x03) !== 0) {
33
+ throw new Error("relay frame unknown flag bits");
34
+ }
35
+
36
+ const payloadLength = (bytes[16] << 8) | bytes[17];
37
+ if (bytes.length !== RELAY_FRAME_HEADER_SIZE + payloadLength) {
38
+ throw new Error("relay frame payload length mismatch");
39
+ }
40
+
41
+ const expectedCrc = readU16(bytes, RELAY_FRAME_CRC_OFFSET);
42
+ const actualCrc = relayFrameCrc16(bytes.slice(0, 18), bytes.slice(RELAY_FRAME_HEADER_SIZE));
43
+ if (actualCrc !== expectedCrc) {
44
+ throw new Error("relay frame crc mismatch");
45
+ }
46
+
47
+ let sequence = 0n;
48
+ for (let i = 4; i < 12; i += 1) {
49
+ sequence = (sequence << 8n) | BigInt(bytes[i]);
50
+ }
51
+
52
+ const offset = ((bytes[12] << 24) | (bytes[13] << 16) | (bytes[14] << 8) | bytes[15]) >>> 0;
53
+ const first = (bytes[2] & 1) !== 0;
54
+ // bit 0 marks the first chunk (spec relay-chunks.md), which is the frame at
55
+ // offset 0. Validate the flag instead of decoding it into a field nothing
56
+ // checks: it must be set exactly when offset is zero.
57
+ if (first !== (offset === 0)) {
58
+ throw new Error("relay frame first flag must be set iff offset is zero");
59
+ }
60
+ return {
61
+ version,
62
+ sourceType: bytes[1],
63
+ first,
64
+ final: (bytes[2] & 2) !== 0,
65
+ sequence,
66
+ offset,
67
+ payload: bytes.slice(RELAY_FRAME_HEADER_SIZE)
68
+ };
69
+ }
70
+
71
+ function readU16(bytes: Uint8Array, offset: number): number {
72
+ return (bytes[offset] << 8) | bytes[offset + 1];
73
+ }
74
+
75
+ function relayFrameCrc16(headerWithoutCrc: Uint8Array, payload: Uint8Array): number {
76
+ let crc = crc16Update(RELAY_FRAME_CRC_INITIAL, headerWithoutCrc);
77
+ crc = crc16Update(crc, payload);
78
+ return crc;
79
+ }
80
+
81
+ function crc16Update(initialCrc: number, bytes: Uint8Array): number {
82
+ let crc = initialCrc;
83
+ for (const byte of bytes) {
84
+ crc ^= byte << 8;
85
+ for (let bit = 0; bit < 8; bit += 1) {
86
+ if ((crc & 0x8000) !== 0) {
87
+ crc = ((crc << 1) ^ RELAY_FRAME_CRC_POLYNOMIAL) & 0xffff;
88
+ } else {
89
+ crc = (crc << 1) & 0xffff;
90
+ }
91
+ }
92
+ }
93
+ return crc;
94
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ export { decodeRelayFrame } from "./frame";
2
+ export type { RelayFrame } from "./frame";
3
+ export { createInMemoryRelayQueue } from "./relayQueue";
4
+ export { createDurableRelayQueue } from "./relayQueue";
5
+ export type { RelayQueue, StoredRelayMessage } from "./relayQueue";
6
+ export { createMemoryDurableStore } from "./durableStore";
7
+ export type { DurableRelayChunk, RelayDurableStore } from "./durableStore";
8
+ export { createMmkvRelayStore } from "./storage/mmkvStore";
9
+ export type { MmkvRelayStoreOptions } from "./storage/mmkvStore";
10
+ export { buildRelayUploadBuffer, uploadRelayMessage, uploadRelayMessageOutcome } from "./uploader";
11
+ export type { RelayUploaderConfig, RelayUploadOutcome } from "./uploader";
12
+ export { drainRelayQueue } from "./drain";
13
+ export type { DrainRelayQueueOptions } from "./drain";
14
+ export { nextBackoffDelayMs } from "./retry";
15
+ export { buildRelayAck, createRelayFrameReceiver } from "./relayFrameReceiver";
16
+ export type {
17
+ RelayFrameAcknowledgement,
18
+ RelayFrameAcknowledger,
19
+ RelayFrameReceipt,
20
+ RelayFrameReceiverOptions,
21
+ RelayReceiveFrameOptions
22
+ } from "./relayFrameReceiver";
23
+ export { createRelayUploadScheduler } from "./scheduler";
24
+ export type { RelayUploadSchedulerNative, RelayUploadSchedulerOptions } from "./scheduler";
25
+ export { createMobileRelay } from "./mobileRelay";
26
+ export type { MobileRelayOptions } from "./mobileRelay";
27
+ export { createRelayNativeBindings } from "./nativeModule";
28
+ export type { RelayNativeBindings, RelayNativeModule } from "./nativeModule";
@@ -0,0 +1,73 @@
1
+ import { drainRelayQueue } from "./drain";
2
+ import { createRelayFrameReceiver, type RelayReceiveFrameOptions } from "./relayFrameReceiver";
3
+ import { createDurableRelayQueue, type StoredRelayMessage } from "./relayQueue";
4
+ import { createRelayUploadScheduler } from "./scheduler";
5
+ import { uploadRelayMessageOutcome, type RelayUploaderConfig } from "./uploader";
6
+ import type { RelayDurableStore } from "./durableStore";
7
+ import type { RelayUploadSchedulerNative } from "./scheduler";
8
+
9
+ export type MobileRelayOptions = {
10
+ durableStore: RelayDurableStore;
11
+ uploaderConfig: Partial<RelayUploaderConfig> & Pick<RelayUploaderConfig, 'projectKey' | 'relayId' | 'relaySdkVersion' | 'streamId' | 'messageId'>;
12
+ schedulerNative?: RelayUploadSchedulerNative;
13
+ random?: () => number;
14
+ };
15
+
16
+ export function createMobileRelay(options: MobileRelayOptions) {
17
+ const uploaderConfig: RelayUploaderConfig = {
18
+ endpointUrl: "https://i.honch.io",
19
+ relaySdkPlatform: "ios",
20
+ ...options.uploaderConfig,
21
+ };
22
+
23
+ const queue = createDurableRelayQueue(options.durableStore);
24
+ const receiver = createRelayFrameReceiver({ queue });
25
+ const scheduler =
26
+ options.schedulerNative === undefined
27
+ ? undefined
28
+ : createRelayUploadScheduler({ native: options.schedulerNative });
29
+
30
+ return {
31
+ receiveFrame(deviceId: string, frameBytes: Uint8Array, receiveOptions?: RelayReceiveFrameOptions) {
32
+ return receiver.receiveFrame(deviceId, frameBytes, receiveOptions);
33
+ },
34
+
35
+ pending(): Promise<StoredRelayMessage[]> {
36
+ return queue.pending();
37
+ },
38
+
39
+ async startUploadScheduler(): Promise<void> {
40
+ if (scheduler === undefined) {
41
+ await this.drainUploads();
42
+ return;
43
+ }
44
+ await scheduler.schedule(0);
45
+ await this.drainUploads();
46
+ },
47
+
48
+ async stopUploadScheduler(): Promise<void> {
49
+ if (scheduler === undefined) {
50
+ return;
51
+ }
52
+ await scheduler.cancel();
53
+ },
54
+
55
+ drainUploads(): Promise<void> {
56
+ const drainStartedAtMs = Date.now();
57
+ return drainRelayQueue({
58
+ queue,
59
+ upload: (message) => uploadRelayMessageOutcome(uploaderConfig, message),
60
+ now: () => drainStartedAtMs,
61
+ random: options.random
62
+ }).then(async () => {
63
+ const pending = await queue.pending();
64
+ const dueTimes = pending
65
+ .map((message) => message.nextAttemptAtMs)
66
+ .filter((value): value is number => value !== undefined);
67
+ if (dueTimes.length > 0) {
68
+ await scheduler?.schedule(Math.max(0, Math.min(...dueTimes) - drainStartedAtMs));
69
+ }
70
+ });
71
+ }
72
+ };
73
+ }
@@ -0,0 +1,29 @@
1
+ import type { RelayUploadSchedulerNative } from "./scheduler";
2
+
3
+ export type RelayNativeModule = RelayUploadSchedulerNative;
4
+
5
+ export type RelayNativeBindings = {
6
+ schedulerNative: RelayUploadSchedulerNative;
7
+ };
8
+
9
+ const requiredMethods = [
10
+ "scheduleUpload",
11
+ "cancelUpload"
12
+ ] as const;
13
+
14
+ export function createRelayNativeBindings(nativeModule: unknown): RelayNativeBindings {
15
+ const module = nativeModule as Partial<Record<(typeof requiredMethods)[number], unknown>>;
16
+ for (const method of requiredMethods) {
17
+ if (typeof module[method] !== "function") {
18
+ throw new Error(`missing native relay method: ${method}`);
19
+ }
20
+ }
21
+
22
+ const typedModule = nativeModule as RelayNativeModule;
23
+ return {
24
+ schedulerNative: {
25
+ scheduleUpload: (delayMs) => typedModule.scheduleUpload(delayMs),
26
+ cancelUpload: () => typedModule.cancelUpload()
27
+ }
28
+ };
29
+ }
@@ -0,0 +1,67 @@
1
+ import type { RelayQueue, StoredRelayMessage } from "./relayQueue";
2
+
3
+ export type RelayFrameAcknowledgement = {
4
+ deviceId: string;
5
+ sequence: string;
6
+ ackBytes: Uint8Array;
7
+ message: StoredRelayMessage;
8
+ };
9
+
10
+ export type RelayFrameAcknowledger = (
11
+ acknowledgement: RelayFrameAcknowledgement
12
+ ) => Promise<void> | void;
13
+
14
+ export type RelayFrameReceipt = {
15
+ complete: boolean;
16
+ message?: StoredRelayMessage;
17
+ ackBytes?: Uint8Array;
18
+ };
19
+
20
+ export type RelayFrameReceiverOptions = {
21
+ queue: RelayQueue;
22
+ };
23
+
24
+ export type RelayReceiveFrameOptions = {
25
+ acknowledge?: RelayFrameAcknowledger;
26
+ };
27
+
28
+ export function buildRelayAck(sequence: string | number | bigint): Uint8Array {
29
+ let value = BigInt(sequence);
30
+ if (value < 0n || value > 0xffff_ffff_ffff_ffffn) {
31
+ throw new Error("relay ACK sequence out of uint64 range");
32
+ }
33
+
34
+ const ackBytes = new Uint8Array(9);
35
+ ackBytes[0] = 1;
36
+ for (let index = 8; index >= 1; index -= 1) {
37
+ ackBytes[index] = Number(value & 0xffn);
38
+ value >>= 8n;
39
+ }
40
+ return ackBytes;
41
+ }
42
+
43
+ export function createRelayFrameReceiver(options: RelayFrameReceiverOptions) {
44
+ return {
45
+ async receiveFrame(
46
+ deviceId: string,
47
+ frameBytes: Uint8Array,
48
+ receiveOptions: RelayReceiveFrameOptions = {}
49
+ ): Promise<RelayFrameReceipt> {
50
+ const result = await options.queue.putChunk(deviceId, frameBytes);
51
+ if (!result.complete || result.message === undefined) {
52
+ return { complete: false };
53
+ }
54
+
55
+ const ackBytes = buildRelayAck(result.message.sequence);
56
+ if (receiveOptions.acknowledge !== undefined) {
57
+ await receiveOptions.acknowledge({
58
+ deviceId,
59
+ sequence: result.message.sequence,
60
+ ackBytes,
61
+ message: result.message
62
+ });
63
+ }
64
+ return { complete: true, message: result.message, ackBytes };
65
+ }
66
+ };
67
+ }
@@ -0,0 +1,282 @@
1
+ import { decodeRelayFrame } from "./frame";
2
+ import type { DurableRelayChunk, RelayDurableStore } from "./durableStore";
3
+
4
+ export type StoredRelayMessage = {
5
+ deviceId: string;
6
+ sourceType: number;
7
+ sequence: string;
8
+ body: Uint8Array;
9
+ retryAttempt?: number;
10
+ nextAttemptAtMs?: number;
11
+ };
12
+
13
+ export type RelayRetryState = {
14
+ attempt: number;
15
+ nextAttemptAtMs: number;
16
+ };
17
+
18
+ export interface RelayQueue {
19
+ putChunk(
20
+ deviceId: string,
21
+ frameBytes: Uint8Array
22
+ ): Promise<{ complete: boolean; message?: StoredRelayMessage }>;
23
+ markUploaded(deviceId: string, sequence: string): Promise<void>;
24
+ markDropped(deviceId: string, sequence: string): Promise<void>;
25
+ markRetry(deviceId: string, sequence: string, retry: RelayRetryState): Promise<void>;
26
+ pending(): Promise<StoredRelayMessage[]>;
27
+ }
28
+
29
+ type StoredChunk = {
30
+ offset: number;
31
+ bytes: Uint8Array;
32
+ payload: Uint8Array;
33
+ };
34
+
35
+ type Assembly = {
36
+ deviceId: string;
37
+ sourceType: number;
38
+ sequence: string;
39
+ chunks: StoredChunk[];
40
+ finalEnd?: number;
41
+ message?: StoredRelayMessage;
42
+ };
43
+
44
+ export function createInMemoryRelayQueue(): RelayQueue {
45
+ const assemblies = new Map<string, Assembly>();
46
+ const completeMessages: StoredRelayMessage[] = [];
47
+
48
+ function key(deviceId: string, sequence: string): string {
49
+ return `${deviceId}\0${sequence}`;
50
+ }
51
+
52
+ function findChunk(assembly: Assembly, offset: number): StoredChunk | undefined {
53
+ return assembly.chunks.find((chunk) => chunk.offset === offset);
54
+ }
55
+
56
+ function completeMessage(assembly: Assembly): StoredRelayMessage | undefined {
57
+ if (assembly.finalEnd === undefined) {
58
+ return undefined;
59
+ }
60
+
61
+ const chunks = [...assembly.chunks].sort((left, right) => left.offset - right.offset);
62
+ let cursor = 0;
63
+ for (const chunk of chunks) {
64
+ if (chunk.offset !== cursor) {
65
+ return undefined;
66
+ }
67
+ cursor += chunk.payload.length;
68
+ }
69
+ if (cursor !== assembly.finalEnd) {
70
+ return undefined;
71
+ }
72
+
73
+ const body = new Uint8Array(cursor);
74
+ for (const chunk of chunks) {
75
+ body.set(chunk.payload, chunk.offset);
76
+ }
77
+ return {
78
+ deviceId: assembly.deviceId,
79
+ sourceType: assembly.sourceType,
80
+ sequence: assembly.sequence,
81
+ body
82
+ };
83
+ }
84
+
85
+ return {
86
+ async putChunk(deviceId, frameBytes) {
87
+ const frame = decodeRelayFrame(frameBytes);
88
+ const sequence = frame.sequence.toString();
89
+ const assemblyKey = key(deviceId, sequence);
90
+ let assembly = assemblies.get(assemblyKey);
91
+ if (assembly === undefined) {
92
+ assembly = {
93
+ deviceId,
94
+ sourceType: frame.sourceType,
95
+ sequence,
96
+ chunks: []
97
+ };
98
+ assemblies.set(assemblyKey, assembly);
99
+ } else if (assembly.sourceType !== frame.sourceType) {
100
+ throw new Error("relay frame source type mismatch");
101
+ }
102
+
103
+ const existing = findChunk(assembly, frame.offset);
104
+ if (existing !== undefined) {
105
+ if (!bytesEqual(existing.bytes, frameBytes)) {
106
+ throw new Error("relay duplicate chunk mismatch");
107
+ }
108
+ return { complete: assembly.message !== undefined, message: assembly.message };
109
+ }
110
+
111
+ if (frame.final) {
112
+ const finalEnd = frame.offset + frame.payload.length;
113
+ if (assembly.finalEnd !== undefined && assembly.finalEnd !== finalEnd) {
114
+ throw new Error("relay final chunk length mismatch");
115
+ }
116
+ assembly.finalEnd = finalEnd;
117
+ }
118
+ assembly.chunks.push({
119
+ offset: frame.offset,
120
+ bytes: new Uint8Array(frameBytes),
121
+ payload: new Uint8Array(frame.payload)
122
+ });
123
+
124
+ const message = completeMessage(assembly);
125
+ if (message === undefined) {
126
+ return { complete: false };
127
+ }
128
+
129
+ assembly.message = message;
130
+ if (!completeMessages.some((entry) => entry.deviceId === deviceId && entry.sequence === sequence)) {
131
+ completeMessages.push(message);
132
+ }
133
+ return { complete: true, message };
134
+ },
135
+
136
+ async markUploaded(deviceId, sequence) {
137
+ const assemblyKey = key(deviceId, sequence);
138
+ assemblies.delete(assemblyKey);
139
+ const index = completeMessages.findIndex(
140
+ (message) => message.deviceId === deviceId && message.sequence === sequence
141
+ );
142
+ if (index >= 0) {
143
+ completeMessages.splice(index, 1);
144
+ }
145
+ },
146
+
147
+ async markDropped(deviceId, sequence) {
148
+ await this.markUploaded(deviceId, sequence);
149
+ },
150
+
151
+ async markRetry(deviceId, sequence, retry) {
152
+ const message = completeMessages.find(
153
+ (entry) => entry.deviceId === deviceId && entry.sequence === sequence
154
+ );
155
+ if (message !== undefined) {
156
+ message.retryAttempt = retry.attempt;
157
+ message.nextAttemptAtMs = retry.nextAttemptAtMs;
158
+ }
159
+ },
160
+
161
+ async pending() {
162
+ return completeMessages.map((message) => ({
163
+ ...message,
164
+ body: new Uint8Array(message.body)
165
+ }));
166
+ }
167
+ };
168
+ }
169
+
170
+ export function createDurableRelayQueue(store: RelayDurableStore): RelayQueue {
171
+ return {
172
+ async putChunk(deviceId, frameBytes) {
173
+ const frame = decodeRelayFrame(frameBytes);
174
+ const sequence = frame.sequence.toString();
175
+ const existingChunks = await store.chunks(deviceId, sequence);
176
+ const existing = existingChunks.find((chunk) => chunk.offset === frame.offset);
177
+ if (existing !== undefined) {
178
+ if (!bytesEqual(existing.frameBytes, frameBytes)) {
179
+ throw new Error("relay duplicate chunk mismatch");
180
+ }
181
+ const existingMessage = (await store.completeMessages()).find(
182
+ (message) => message.deviceId === deviceId && message.sequence === sequence
183
+ );
184
+ return { complete: existingMessage !== undefined, message: existingMessage };
185
+ }
186
+
187
+ if (
188
+ existingChunks.length > 0 &&
189
+ existingChunks.some((chunk) => chunk.sourceType !== frame.sourceType)
190
+ ) {
191
+ throw new Error("relay frame source type mismatch");
192
+ }
193
+
194
+ const chunk: DurableRelayChunk = {
195
+ deviceId,
196
+ sourceType: frame.sourceType,
197
+ sequence,
198
+ offset: frame.offset,
199
+ frameBytes: new Uint8Array(frameBytes),
200
+ payload: new Uint8Array(frame.payload)
201
+ };
202
+ if (frame.final) {
203
+ const finalEnd = frame.offset + frame.payload.length;
204
+ const existingFinalEnd = existingChunks.find((entry) => entry.finalEnd !== undefined)?.finalEnd;
205
+ if (existingFinalEnd !== undefined && existingFinalEnd !== finalEnd) {
206
+ throw new Error("relay final chunk length mismatch");
207
+ }
208
+ chunk.finalEnd = finalEnd;
209
+ }
210
+ await store.putChunk(chunk);
211
+
212
+ const message = completeMessageFromChunks(deviceId, frame.sourceType, sequence, [
213
+ ...existingChunks,
214
+ chunk
215
+ ]);
216
+ if (message === undefined) {
217
+ return { complete: false };
218
+ }
219
+
220
+ await store.putCompleteMessage(message);
221
+ return { complete: true, message };
222
+ },
223
+
224
+ async markUploaded(deviceId, sequence) {
225
+ await store.deleteMessage(deviceId, sequence);
226
+ },
227
+
228
+ async markDropped(deviceId, sequence) {
229
+ await store.deleteMessage(deviceId, sequence);
230
+ },
231
+
232
+ async markRetry(deviceId, sequence, retry) {
233
+ await store.markRetry(deviceId, sequence, retry);
234
+ },
235
+
236
+ async pending() {
237
+ return store.completeMessages();
238
+ }
239
+ };
240
+ }
241
+
242
+ function completeMessageFromChunks(
243
+ deviceId: string,
244
+ sourceType: number,
245
+ sequence: string,
246
+ chunks: DurableRelayChunk[]
247
+ ): StoredRelayMessage | undefined {
248
+ const finalEnd = chunks.find((chunk) => chunk.finalEnd !== undefined)?.finalEnd;
249
+ if (finalEnd === undefined) {
250
+ return undefined;
251
+ }
252
+
253
+ const sorted = [...chunks].sort((left, right) => left.offset - right.offset);
254
+ let cursor = 0;
255
+ for (const chunk of sorted) {
256
+ if (chunk.offset !== cursor) {
257
+ return undefined;
258
+ }
259
+ cursor += chunk.payload.length;
260
+ }
261
+ if (cursor !== finalEnd) {
262
+ return undefined;
263
+ }
264
+
265
+ const body = new Uint8Array(cursor);
266
+ for (const chunk of sorted) {
267
+ body.set(chunk.payload, chunk.offset);
268
+ }
269
+ return { deviceId, sourceType, sequence, body };
270
+ }
271
+
272
+ function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
273
+ if (left.length !== right.length) {
274
+ return false;
275
+ }
276
+ for (let i = 0; i < left.length; i += 1) {
277
+ if (left[i] !== right[i]) {
278
+ return false;
279
+ }
280
+ }
281
+ return true;
282
+ }