@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,622 @@
1
+ import type { MMKV } from "react-native-mmkv";
2
+
3
+ import type { DurableRelayChunk, RelayDurableStore } from "../durableStore";
4
+ import type { RelayRetryState } from "../relayQueue";
5
+ import type { StoredRelayMessage } from "../relayQueue";
6
+
7
+ type RelayMmkvStorage = Pick<MMKV, "getString" | "set" | "remove">;
8
+
9
+ export type MmkvRelayStoreOptions = {
10
+ maxChunks?: number;
11
+ maxCompleteMessages?: number;
12
+ ttlMs?: number;
13
+ keyPrefix?: string;
14
+ now?: () => number;
15
+ };
16
+
17
+ type SerializedRelayStoreIndex = {
18
+ chunks?: SerializedRelayChunkIndexEntry[];
19
+ messages: SerializedRelayMessageIndexEntry[];
20
+ };
21
+
22
+ type SerializedRelaySequenceChunkIndex = {
23
+ chunks: SerializedRelayChunkIndexEntry[];
24
+ };
25
+
26
+ type SerializedRelayChunkOrderState = {
27
+ nextOrdinal: number;
28
+ oldestOrdinal: number;
29
+ count: number;
30
+ };
31
+
32
+ type SerializedRelayChunkOrderEntry = {
33
+ key: string;
34
+ deviceId: string;
35
+ sequence: string;
36
+ offset: number;
37
+ };
38
+
39
+ type SerializedRelayChunkIndexEntry = {
40
+ key: string;
41
+ deviceId: string;
42
+ sourceType: number;
43
+ sequence: string;
44
+ offset: number;
45
+ storedAtMs: number;
46
+ finalEnd?: number;
47
+ };
48
+
49
+ type SerializedRelayMessageIndexEntry = {
50
+ key: string;
51
+ deviceId: string;
52
+ sourceType: number;
53
+ sequence: string;
54
+ storedAtMs: number;
55
+ retryAttempt?: number;
56
+ nextAttemptAtMs?: number;
57
+ };
58
+
59
+ type SerializedRelayChunkRecord = {
60
+ frameBase64?: string;
61
+ payloadBase64?: string;
62
+ frameBytes?: number[];
63
+ payload?: number[];
64
+ };
65
+
66
+ type SerializedRelayMessageRecord = {
67
+ bodyBase64?: string;
68
+ body?: number[];
69
+ };
70
+
71
+ type LegacySerializedRelayStoreState = {
72
+ chunks?: LegacySerializedRelayChunk[];
73
+ messages?: LegacySerializedRelayMessage[];
74
+ };
75
+
76
+ type LegacySerializedRelayChunk = {
77
+ deviceId: string;
78
+ sourceType: number;
79
+ sequence: string;
80
+ offset: number;
81
+ frameBytes: number[];
82
+ payload: number[];
83
+ finalEnd?: number;
84
+ };
85
+
86
+ type LegacySerializedRelayMessage = {
87
+ deviceId: string;
88
+ sourceType: number;
89
+ sequence: string;
90
+ body: number[];
91
+ };
92
+
93
+ const LEGACY_RELAY_STORE_KEY = "honch.relay.queue.v1";
94
+ const DEFAULT_KEY_PREFIX = "honch.relay";
95
+ const DEFAULT_MAX_CHUNKS = 4096;
96
+ const DEFAULT_MAX_COMPLETE_MESSAGES = 1024;
97
+ // No time-based expiry by default: retention is bounded by the count caps with
98
+ // drop-oldest, consistent with the other relay stores and the core SDK's queue
99
+ // policy (which never time-expires queued data). Callers can still opt into a
100
+ // finite ttlMs. Infinity makes (now - ttlMs) always in the past, so the prune
101
+ // step keeps every entry on time and only the count caps shed data.
102
+ const DEFAULT_TTL_MS = Number.POSITIVE_INFINITY;
103
+
104
+ export function createMmkvRelayStore(
105
+ storage: RelayMmkvStorage,
106
+ options: MmkvRelayStoreOptions = {}
107
+ ): RelayDurableStore {
108
+ return new MmkvRelayStore(storage, {
109
+ maxChunks: options.maxChunks ?? DEFAULT_MAX_CHUNKS,
110
+ maxCompleteMessages: options.maxCompleteMessages ?? DEFAULT_MAX_COMPLETE_MESSAGES,
111
+ ttlMs: options.ttlMs ?? DEFAULT_TTL_MS,
112
+ keyPrefix: normalizeKeyPrefix(options.keyPrefix),
113
+ now: options.now ?? Date.now
114
+ });
115
+ }
116
+
117
+ class MmkvRelayStore implements RelayDurableStore {
118
+ constructor(
119
+ private readonly storage: RelayMmkvStorage,
120
+ private readonly options: Required<MmkvRelayStoreOptions>
121
+ ) {}
122
+
123
+ async putChunk(chunk: DurableRelayChunk): Promise<void> {
124
+ const sequenceIndex = this.prunedSequenceChunkIndex(chunk.deviceId, chunk.sequence);
125
+ const key = this.chunkKey(chunk.deviceId, chunk.sequence, chunk.offset);
126
+ const storedAtMs = this.options.now();
127
+ const entry: SerializedRelayChunkIndexEntry = {
128
+ key,
129
+ deviceId: chunk.deviceId,
130
+ sourceType: chunk.sourceType,
131
+ sequence: chunk.sequence,
132
+ offset: chunk.offset,
133
+ storedAtMs,
134
+ finalEnd: chunk.finalEnd
135
+ };
136
+
137
+ this.storage.set(
138
+ key,
139
+ JSON.stringify({
140
+ frameBase64: bytesToBase64(chunk.frameBytes),
141
+ payloadBase64: bytesToBase64(chunk.payload)
142
+ } satisfies SerializedRelayChunkRecord)
143
+ );
144
+
145
+ const existing = sequenceIndex.chunks.findIndex(
146
+ (candidate) =>
147
+ candidate.deviceId === chunk.deviceId &&
148
+ candidate.sequence === chunk.sequence &&
149
+ candidate.offset === chunk.offset
150
+ );
151
+ if (existing >= 0) {
152
+ sequenceIndex.chunks[existing] = entry;
153
+ } else {
154
+ sequenceIndex.chunks.push(entry);
155
+ this.appendChunkOrderEntry(entry);
156
+ }
157
+
158
+ this.writeSequenceChunkIndex(chunk.deviceId, chunk.sequence, sequenceIndex.chunks);
159
+ this.enforceChunkLimit();
160
+ }
161
+
162
+ async chunks(deviceId: string, sequence: string): Promise<DurableRelayChunk[]> {
163
+ const sequenceIndex = this.prunedSequenceChunkIndex(deviceId, sequence);
164
+ return sequenceIndex.chunks
165
+ .map((entry) => this.readChunk(entry))
166
+ .filter((chunk): chunk is DurableRelayChunk => chunk !== undefined);
167
+ }
168
+
169
+ async putCompleteMessage(message: StoredRelayMessage): Promise<void> {
170
+ const index = this.prunedIndex();
171
+ const key = this.messageKey(message.deviceId, message.sequence);
172
+ const storedAtMs = this.options.now();
173
+ const entry: SerializedRelayMessageIndexEntry = {
174
+ key,
175
+ deviceId: message.deviceId,
176
+ sourceType: message.sourceType,
177
+ sequence: message.sequence,
178
+ storedAtMs
179
+ };
180
+
181
+ this.storage.set(
182
+ key,
183
+ JSON.stringify({
184
+ bodyBase64: bytesToBase64(message.body)
185
+ } satisfies SerializedRelayMessageRecord)
186
+ );
187
+
188
+ const existing = index.messages.findIndex(
189
+ (candidate) => candidate.deviceId === message.deviceId && candidate.sequence === message.sequence
190
+ );
191
+ if (existing >= 0) {
192
+ index.messages[existing] = entry;
193
+ } else {
194
+ index.messages.push(entry);
195
+ }
196
+
197
+ this.enforceLimits(index);
198
+ this.writeIndex(index);
199
+ }
200
+
201
+ async completeMessages(): Promise<StoredRelayMessage[]> {
202
+ const index = this.prunedIndex();
203
+ this.writeIndex(index);
204
+ return index.messages
205
+ .map((entry) => this.readMessage(entry))
206
+ .filter((message): message is StoredRelayMessage => message !== undefined);
207
+ }
208
+
209
+ async markRetry(deviceId: string, sequence: string, retry: RelayRetryState): Promise<void> {
210
+ const index = this.prunedIndex();
211
+ const entry = index.messages.find(
212
+ (message) => message.deviceId === deviceId && message.sequence === sequence
213
+ );
214
+ if (entry !== undefined) {
215
+ entry.retryAttempt = retry.attempt;
216
+ entry.nextAttemptAtMs = retry.nextAttemptAtMs;
217
+ this.writeIndex(index);
218
+ }
219
+ }
220
+
221
+ async deleteMessage(deviceId: string, sequence: string): Promise<void> {
222
+ const index = this.prunedIndex();
223
+ index.messages = index.messages.filter((message) => {
224
+ if (message.deviceId !== deviceId || message.sequence !== sequence) {
225
+ return true;
226
+ }
227
+ this.storage.remove(message.key);
228
+ return false;
229
+ });
230
+ for (const chunk of this.readSequenceChunkIndex(deviceId, sequence).chunks) {
231
+ this.storage.remove(chunk.key);
232
+ }
233
+ this.storage.remove(this.sequenceChunkIndexKey(deviceId, sequence));
234
+ this.writeIndex(index);
235
+ }
236
+
237
+ private prunedIndex(): SerializedRelayStoreIndex {
238
+ const index = this.readIndex();
239
+ const expiresBeforeMs = this.options.now() - this.options.ttlMs;
240
+
241
+ index.messages = index.messages.filter((message) => {
242
+ if (message.storedAtMs >= expiresBeforeMs) {
243
+ return true;
244
+ }
245
+ this.storage.remove(message.key);
246
+ return false;
247
+ });
248
+ this.enforceLimits(index);
249
+ return index;
250
+ }
251
+
252
+ private enforceLimits(index: SerializedRelayStoreIndex): void {
253
+ index.messages.sort((left, right) => left.storedAtMs - right.storedAtMs);
254
+ while (index.messages.length > this.options.maxCompleteMessages) {
255
+ const [removed] = index.messages.splice(0, 1);
256
+ this.storage.remove(removed.key);
257
+ const sequenceIndex = this.readSequenceChunkIndex(removed.deviceId, removed.sequence);
258
+ for (const chunk of sequenceIndex.chunks) {
259
+ this.storage.remove(chunk.key);
260
+ }
261
+ this.storage.remove(this.sequenceChunkIndexKey(removed.deviceId, removed.sequence));
262
+ }
263
+ }
264
+
265
+ private readIndex(): SerializedRelayStoreIndex {
266
+ const raw = this.storage.getString(this.indexKey());
267
+ if (raw === undefined) {
268
+ return this.migrateLegacyIndex();
269
+ }
270
+ const parsed = JSON.parse(raw) as SerializedRelayStoreIndex;
271
+ return {
272
+ chunks: parsed.chunks ?? [],
273
+ messages: parsed.messages ?? []
274
+ };
275
+ }
276
+
277
+ private writeIndex(index: SerializedRelayStoreIndex): void {
278
+ if ((index.chunks?.length ?? 0) === 0 && index.messages.length === 0) {
279
+ this.storage.remove(this.indexKey());
280
+ return;
281
+ }
282
+ this.storage.set(this.indexKey(), JSON.stringify(index));
283
+ }
284
+
285
+ private readSequenceChunkIndex(
286
+ deviceId: string,
287
+ sequence: string
288
+ ): SerializedRelaySequenceChunkIndex {
289
+ const raw = this.storage.getString(this.sequenceChunkIndexKey(deviceId, sequence));
290
+ if (raw !== undefined) {
291
+ const parsed = JSON.parse(raw) as SerializedRelaySequenceChunkIndex;
292
+ return { chunks: parsed.chunks ?? [] };
293
+ }
294
+
295
+ return this.readLegacySequenceChunkIndex(deviceId, sequence);
296
+ }
297
+
298
+ private prunedSequenceChunkIndex(deviceId: string, sequence: string): SerializedRelaySequenceChunkIndex {
299
+ const sequenceIndex = this.readSequenceChunkIndex(deviceId, sequence);
300
+ const expiresBeforeMs = this.options.now() - this.options.ttlMs;
301
+ const chunks = sequenceIndex.chunks.filter((chunk) => {
302
+ if (chunk.storedAtMs >= expiresBeforeMs) {
303
+ return true;
304
+ }
305
+ this.storage.remove(chunk.key);
306
+ return false;
307
+ });
308
+ this.writeSequenceChunkIndex(deviceId, sequence, chunks);
309
+ return { chunks };
310
+ }
311
+
312
+ private writeSequenceChunkIndex(
313
+ deviceId: string,
314
+ sequence: string,
315
+ chunks: SerializedRelayChunkIndexEntry[]
316
+ ): void {
317
+ const key = this.sequenceChunkIndexKey(deviceId, sequence);
318
+ if (chunks.length === 0) {
319
+ this.storage.remove(key);
320
+ return;
321
+ }
322
+ this.storage.set(key, JSON.stringify({ chunks } satisfies SerializedRelaySequenceChunkIndex));
323
+ }
324
+
325
+ private appendChunkOrderEntry(entry: SerializedRelayChunkIndexEntry): void {
326
+ const state = this.readChunkOrderState();
327
+ const ordinal = state.nextOrdinal;
328
+ this.storage.set(
329
+ this.chunkOrderEntryKey(ordinal),
330
+ JSON.stringify({
331
+ key: entry.key,
332
+ deviceId: entry.deviceId,
333
+ sequence: entry.sequence,
334
+ offset: entry.offset
335
+ } satisfies SerializedRelayChunkOrderEntry)
336
+ );
337
+ this.writeChunkOrderState({
338
+ nextOrdinal: ordinal + 1,
339
+ oldestOrdinal: state.count === 0 ? ordinal : state.oldestOrdinal,
340
+ count: state.count + 1
341
+ });
342
+ }
343
+
344
+ private enforceChunkLimit(): void {
345
+ let state = this.readChunkOrderState();
346
+ while (state.count > this.options.maxChunks) {
347
+ const orderKey = this.chunkOrderEntryKey(state.oldestOrdinal);
348
+ const raw = this.storage.getString(orderKey);
349
+ this.storage.remove(orderKey);
350
+ if (raw !== undefined) {
351
+ const entry = JSON.parse(raw) as SerializedRelayChunkOrderEntry;
352
+ this.storage.remove(entry.key);
353
+ const sequenceIndex = this.readSequenceChunkIndex(entry.deviceId, entry.sequence);
354
+ this.writeSequenceChunkIndex(
355
+ entry.deviceId,
356
+ entry.sequence,
357
+ sequenceIndex.chunks.filter((chunk) => chunk.offset !== entry.offset)
358
+ );
359
+ }
360
+ state = {
361
+ nextOrdinal: state.nextOrdinal,
362
+ oldestOrdinal: state.oldestOrdinal + 1,
363
+ count: state.count - 1
364
+ };
365
+ }
366
+ this.writeChunkOrderState(state);
367
+ }
368
+
369
+ private readChunkOrderState(): SerializedRelayChunkOrderState {
370
+ const raw = this.storage.getString(this.chunkOrderStateKey());
371
+ if (raw === undefined) {
372
+ return { nextOrdinal: 1, oldestOrdinal: 1, count: 0 };
373
+ }
374
+ const parsed = JSON.parse(raw) as Partial<SerializedRelayChunkOrderState>;
375
+ return {
376
+ nextOrdinal: parsed.nextOrdinal ?? 1,
377
+ oldestOrdinal: parsed.oldestOrdinal ?? 1,
378
+ count: parsed.count ?? 0
379
+ };
380
+ }
381
+
382
+ private writeChunkOrderState(state: SerializedRelayChunkOrderState): void {
383
+ if (state.count <= 0) {
384
+ this.storage.remove(this.chunkOrderStateKey());
385
+ return;
386
+ }
387
+ this.storage.set(this.chunkOrderStateKey(), JSON.stringify(state));
388
+ }
389
+
390
+ private readLegacySequenceChunkIndex(
391
+ deviceId: string,
392
+ sequence: string
393
+ ): SerializedRelaySequenceChunkIndex {
394
+ const raw = this.storage.getString(LEGACY_RELAY_STORE_KEY);
395
+ if (raw === undefined) {
396
+ return { chunks: [] };
397
+ }
398
+
399
+ const legacy = JSON.parse(raw) as LegacySerializedRelayStoreState;
400
+ const storedAtMs = this.options.now();
401
+ const chunksByOffset = new Map<number, SerializedRelayChunkIndexEntry>();
402
+ for (const chunk of legacy.chunks ?? []) {
403
+ if (chunk.deviceId !== deviceId || chunk.sequence !== sequence) {
404
+ continue;
405
+ }
406
+ const key = this.chunkKey(chunk.deviceId, chunk.sequence, chunk.offset);
407
+ this.storage.set(
408
+ key,
409
+ JSON.stringify({
410
+ frameBase64: bytesToBase64(Uint8Array.from(chunk.frameBytes)),
411
+ payloadBase64: bytesToBase64(Uint8Array.from(chunk.payload))
412
+ } satisfies SerializedRelayChunkRecord)
413
+ );
414
+ chunksByOffset.set(chunk.offset, {
415
+ key,
416
+ deviceId: chunk.deviceId,
417
+ sourceType: chunk.sourceType,
418
+ sequence: chunk.sequence,
419
+ offset: chunk.offset,
420
+ storedAtMs,
421
+ finalEnd: chunk.finalEnd
422
+ });
423
+ }
424
+ const chunks = [...chunksByOffset.values()].sort((left, right) => left.offset - right.offset);
425
+ this.writeSequenceChunkIndex(deviceId, sequence, chunks);
426
+ return { chunks };
427
+ }
428
+
429
+ private migrateLegacyIndex(): SerializedRelayStoreIndex {
430
+ const raw = this.storage.getString(LEGACY_RELAY_STORE_KEY);
431
+ if (raw === undefined) {
432
+ return { chunks: [], messages: [] };
433
+ }
434
+
435
+ const legacy = JSON.parse(raw) as LegacySerializedRelayStoreState;
436
+ const storedAtMs = this.options.now();
437
+ const index: SerializedRelayStoreIndex = {
438
+ messages: []
439
+ };
440
+ const chunkIndexesBySequence = new Map<string, SerializedRelayChunkIndexEntry[]>();
441
+
442
+ for (const chunk of legacy.chunks ?? []) {
443
+ const key = this.chunkKey(chunk.deviceId, chunk.sequence, chunk.offset);
444
+ this.storage.set(
445
+ key,
446
+ JSON.stringify({
447
+ frameBase64: bytesToBase64(Uint8Array.from(chunk.frameBytes)),
448
+ payloadBase64: bytesToBase64(Uint8Array.from(chunk.payload))
449
+ } satisfies SerializedRelayChunkRecord)
450
+ );
451
+ const entry: SerializedRelayChunkIndexEntry = {
452
+ key,
453
+ deviceId: chunk.deviceId,
454
+ sourceType: chunk.sourceType,
455
+ sequence: chunk.sequence,
456
+ offset: chunk.offset,
457
+ storedAtMs,
458
+ finalEnd: chunk.finalEnd
459
+ };
460
+ const sequenceKey = `${chunk.deviceId}\0${chunk.sequence}`;
461
+ const sequenceIndex = chunkIndexesBySequence.get(sequenceKey) ?? [];
462
+ const existing = sequenceIndex.findIndex((candidate) => candidate.offset === entry.offset);
463
+ if (existing >= 0) {
464
+ sequenceIndex[existing] = entry;
465
+ } else {
466
+ sequenceIndex.push(entry);
467
+ this.appendChunkOrderEntry(entry);
468
+ }
469
+ chunkIndexesBySequence.set(sequenceKey, sequenceIndex);
470
+ }
471
+
472
+ for (const chunks of chunkIndexesBySequence.values()) {
473
+ const [firstChunk] = chunks;
474
+ if (firstChunk !== undefined) {
475
+ this.writeSequenceChunkIndex(
476
+ firstChunk.deviceId,
477
+ firstChunk.sequence,
478
+ chunks.sort((left, right) => left.offset - right.offset)
479
+ );
480
+ }
481
+ }
482
+
483
+ for (const message of legacy.messages ?? []) {
484
+ const key = this.messageKey(message.deviceId, message.sequence);
485
+ this.storage.set(
486
+ key,
487
+ JSON.stringify({
488
+ bodyBase64: bytesToBase64(Uint8Array.from(message.body))
489
+ } satisfies SerializedRelayMessageRecord)
490
+ );
491
+ index.messages.push({
492
+ key,
493
+ deviceId: message.deviceId,
494
+ sourceType: message.sourceType,
495
+ sequence: message.sequence,
496
+ storedAtMs
497
+ });
498
+ }
499
+
500
+ this.storage.remove(LEGACY_RELAY_STORE_KEY);
501
+ return index;
502
+ }
503
+
504
+ private readChunk(entry: SerializedRelayChunkIndexEntry): DurableRelayChunk | undefined {
505
+ const raw = this.storage.getString(entry.key);
506
+ if (raw === undefined) {
507
+ return undefined;
508
+ }
509
+ const record = JSON.parse(raw) as SerializedRelayChunkRecord;
510
+ const frameBytes =
511
+ record.frameBase64 !== undefined ? base64ToBytes(record.frameBase64) : Uint8Array.from(record.frameBytes ?? []);
512
+ const payload =
513
+ record.payloadBase64 !== undefined ? base64ToBytes(record.payloadBase64) : Uint8Array.from(record.payload ?? []);
514
+ return {
515
+ deviceId: entry.deviceId,
516
+ sourceType: entry.sourceType,
517
+ sequence: entry.sequence,
518
+ offset: entry.offset,
519
+ frameBytes,
520
+ payload,
521
+ finalEnd: entry.finalEnd
522
+ };
523
+ }
524
+
525
+ private readMessage(entry: SerializedRelayMessageIndexEntry): StoredRelayMessage | undefined {
526
+ const raw = this.storage.getString(entry.key);
527
+ if (raw === undefined) {
528
+ return undefined;
529
+ }
530
+ const record = JSON.parse(raw) as SerializedRelayMessageRecord;
531
+ const body = record.bodyBase64 !== undefined ? base64ToBytes(record.bodyBase64) : Uint8Array.from(record.body ?? []);
532
+ return {
533
+ deviceId: entry.deviceId,
534
+ sourceType: entry.sourceType,
535
+ sequence: entry.sequence,
536
+ body,
537
+ retryAttempt: entry.retryAttempt,
538
+ nextAttemptAtMs: entry.nextAttemptAtMs
539
+ };
540
+ }
541
+
542
+ private indexKey(): string {
543
+ return `${this.options.keyPrefix}.index.v2`;
544
+ }
545
+
546
+ private chunkOrderStateKey(): string {
547
+ return `${this.options.keyPrefix}.chunkOrder.state.v1`;
548
+ }
549
+
550
+ private chunkOrderEntryKey(ordinal: number): string {
551
+ return `${this.options.keyPrefix}.chunkOrder.${ordinal}`;
552
+ }
553
+
554
+ private chunkKey(deviceId: string, sequence: string, offset: number): string {
555
+ return `${this.options.keyPrefix}.chunk.${deviceId}.${sequence}.${offset}`;
556
+ }
557
+
558
+ private sequenceChunkIndexKey(deviceId: string, sequence: string): string {
559
+ return `${this.options.keyPrefix}.chunks.${deviceId}.${sequence}.index.v1`;
560
+ }
561
+
562
+ private messageKey(deviceId: string, sequence: string): string {
563
+ return `${this.options.keyPrefix}.message.${deviceId}.${sequence}`;
564
+ }
565
+ }
566
+
567
+ function normalizeKeyPrefix(keyPrefix: string | undefined): string {
568
+ const trimmed = keyPrefix?.trim().replace(/\.+$/g, "");
569
+ return trimmed === undefined || trimmed.length === 0 ? DEFAULT_KEY_PREFIX : trimmed;
570
+ }
571
+
572
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
573
+
574
+ function bytesToBase64(bytes: Uint8Array): string {
575
+ let output = "";
576
+ for (let offset = 0; offset < bytes.length; offset += 3) {
577
+ const first = bytes[offset] ?? 0;
578
+ const second = bytes[offset + 1] ?? 0;
579
+ const third = bytes[offset + 2] ?? 0;
580
+ const packed = (first << 16) | (second << 8) | third;
581
+ output += BASE64_ALPHABET[(packed >>> 18) & 0x3f];
582
+ output += BASE64_ALPHABET[(packed >>> 12) & 0x3f];
583
+ output += offset + 1 < bytes.length ? BASE64_ALPHABET[(packed >>> 6) & 0x3f] : "=";
584
+ output += offset + 2 < bytes.length ? BASE64_ALPHABET[packed & 0x3f] : "=";
585
+ }
586
+ return output;
587
+ }
588
+
589
+ function base64ToBytes(base64: string): Uint8Array {
590
+ const sanitized = base64.replace(/\s/g, "");
591
+ const bytes: number[] = [];
592
+ for (let offset = 0; offset < sanitized.length; offset += 4) {
593
+ const chunk = sanitized.slice(offset, offset + 4);
594
+ const first = decodeBase64Character(chunk[0]);
595
+ const second = decodeBase64Character(chunk[1]);
596
+ const third = decodeBase64Character(chunk[2]);
597
+ const fourth = decodeBase64Character(chunk[3]);
598
+ if (first === undefined || second === undefined) {
599
+ break;
600
+ }
601
+
602
+ bytes.push((first << 2) | (second >> 4));
603
+ if (chunk[2] !== "=" && third !== undefined) {
604
+ bytes.push(((second & 0x0f) << 4) | (third >> 2));
605
+ }
606
+ if (chunk[3] !== "=" && third !== undefined && fourth !== undefined) {
607
+ bytes.push(((third & 0x03) << 6) | fourth);
608
+ }
609
+ }
610
+ return Uint8Array.from(bytes);
611
+ }
612
+
613
+ function decodeBase64Character(character: string | undefined): number | undefined {
614
+ if (character === undefined) {
615
+ return undefined;
616
+ }
617
+ if (character === "=") {
618
+ return 0;
619
+ }
620
+ const value = BASE64_ALPHABET.indexOf(character);
621
+ return value >= 0 ? value : undefined;
622
+ }