@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
|
@@ -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
|
+
}
|