@prismer/sdk 1.3.4 → 1.7.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 +395 -7
- package/dist/chunk-Y6FXYEAI.mjs +10 -0
- package/dist/cli.js +1365 -12
- package/dist/index.d.mts +739 -8
- package/dist/index.d.ts +739 -8
- package/dist/index.js +2116 -12
- package/dist/index.mjs +2102 -12
- package/dist/webhook.d.mts +114 -0
- package/dist/webhook.d.ts +114 -0
- package/dist/webhook.js +200 -0
- package/dist/webhook.mjs +175 -0
- package/package.json +6 -1
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__require
|
|
3
|
+
} from "./chunk-Y6FXYEAI.mjs";
|
|
4
|
+
|
|
1
5
|
// src/realtime.ts
|
|
2
6
|
var TypedEmitter = class {
|
|
3
7
|
constructor() {
|
|
@@ -425,11 +429,1844 @@ var RealtimeSSEClient = class extends TypedEmitter {
|
|
|
425
429
|
}
|
|
426
430
|
};
|
|
427
431
|
|
|
432
|
+
// src/offline.ts
|
|
433
|
+
var OfflineEmitter = class {
|
|
434
|
+
constructor() {
|
|
435
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
436
|
+
}
|
|
437
|
+
on(event, cb) {
|
|
438
|
+
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
439
|
+
this.listeners.get(event).add(cb);
|
|
440
|
+
return this;
|
|
441
|
+
}
|
|
442
|
+
off(event, cb) {
|
|
443
|
+
this.listeners.get(event)?.delete(cb);
|
|
444
|
+
return this;
|
|
445
|
+
}
|
|
446
|
+
emit(event, payload) {
|
|
447
|
+
const set = this.listeners.get(event);
|
|
448
|
+
if (set) for (const cb of set) {
|
|
449
|
+
try {
|
|
450
|
+
cb(payload);
|
|
451
|
+
} catch {
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
removeAllListeners() {
|
|
456
|
+
this.listeners.clear();
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
function generateId() {
|
|
460
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
461
|
+
return crypto.randomUUID();
|
|
462
|
+
}
|
|
463
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
464
|
+
const r = Math.random() * 16 | 0;
|
|
465
|
+
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
var WRITE_PATTERNS = [
|
|
469
|
+
{ method: "POST", pattern: /\/api\/im\/(messages|direct|groups)\//, opType: "message.send" },
|
|
470
|
+
{ method: "PATCH", pattern: /\/api\/im\/messages\//, opType: "message.edit" },
|
|
471
|
+
{ method: "DELETE", pattern: /\/api\/im\/messages\//, opType: "message.delete" },
|
|
472
|
+
{ method: "POST", pattern: /\/api\/im\/conversations\/[^/]+\/read/, opType: "conversation.read" }
|
|
473
|
+
];
|
|
474
|
+
function matchWriteOp(method, path) {
|
|
475
|
+
for (const { method: m, pattern, opType } of WRITE_PATTERNS) {
|
|
476
|
+
if (method === m && pattern.test(path)) return opType;
|
|
477
|
+
}
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
var OfflineManager = class extends OfflineEmitter {
|
|
481
|
+
constructor(storage, networkRequest, options = {}) {
|
|
482
|
+
super();
|
|
483
|
+
this.flushTimer = null;
|
|
484
|
+
this.flushing = false;
|
|
485
|
+
this._isOnline = true;
|
|
486
|
+
this._syncState = "idle";
|
|
487
|
+
this.sseSource = null;
|
|
488
|
+
this.sseReconnectTimer = null;
|
|
489
|
+
this.sseReconnectAttempts = 0;
|
|
490
|
+
/** Presence cache for realtime presence events */
|
|
491
|
+
this.presenceCache = /* @__PURE__ */ new Map();
|
|
492
|
+
this.storage = storage;
|
|
493
|
+
this.networkRequest = networkRequest;
|
|
494
|
+
this.options = {
|
|
495
|
+
syncOnConnect: options.syncOnConnect ?? true,
|
|
496
|
+
outboxRetryLimit: options.outboxRetryLimit ?? 5,
|
|
497
|
+
outboxFlushInterval: options.outboxFlushInterval ?? 1e3,
|
|
498
|
+
conflictStrategy: options.conflictStrategy ?? "server",
|
|
499
|
+
onConflict: options.onConflict,
|
|
500
|
+
syncMode: options.syncMode ?? "push",
|
|
501
|
+
quota: options.quota ? {
|
|
502
|
+
maxStorageBytes: options.quota.maxStorageBytes ?? 500 * 1024 * 1024,
|
|
503
|
+
warningThreshold: options.quota.warningThreshold ?? 0.9
|
|
504
|
+
} : void 0
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
get isOnline() {
|
|
508
|
+
return this._isOnline;
|
|
509
|
+
}
|
|
510
|
+
get syncState() {
|
|
511
|
+
return this._syncState;
|
|
512
|
+
}
|
|
513
|
+
async init() {
|
|
514
|
+
await this.storage.init();
|
|
515
|
+
this.startFlushTimer();
|
|
516
|
+
}
|
|
517
|
+
async destroy() {
|
|
518
|
+
this.stopFlushTimer();
|
|
519
|
+
this.stopContinuousSync();
|
|
520
|
+
this.removeAllListeners();
|
|
521
|
+
}
|
|
522
|
+
// ── Network state ─────────────────────────────────────────
|
|
523
|
+
setOnline(online) {
|
|
524
|
+
if (this._isOnline === online) return;
|
|
525
|
+
this._isOnline = online;
|
|
526
|
+
this.emit(online ? "network.online" : "network.offline", void 0);
|
|
527
|
+
if (online) {
|
|
528
|
+
this.flush();
|
|
529
|
+
if (this.options.syncOnConnect) {
|
|
530
|
+
if (this.options.syncMode === "push") {
|
|
531
|
+
this.startContinuousSync();
|
|
532
|
+
} else {
|
|
533
|
+
this.sync();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
} else {
|
|
537
|
+
this.stopContinuousSync();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// ── Request dispatch ──────────────────────────────────────
|
|
541
|
+
/**
|
|
542
|
+
* Dispatch an IM request. Write ops go through outbox; reads check local cache.
|
|
543
|
+
*/
|
|
544
|
+
async dispatch(method, path, body, query) {
|
|
545
|
+
const opType = matchWriteOp(method, path);
|
|
546
|
+
if (opType) {
|
|
547
|
+
return this.dispatchWrite(opType, method, path, body, query);
|
|
548
|
+
}
|
|
549
|
+
if (method === "GET") {
|
|
550
|
+
const cached = await this.readFromCache(path, query);
|
|
551
|
+
if (cached !== null) return cached;
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
const result = await this.networkRequest(method, path, body, query);
|
|
555
|
+
if (method === "GET") this.cacheReadResult(path, query, result);
|
|
556
|
+
return result;
|
|
557
|
+
} catch {
|
|
558
|
+
if (!this._isOnline) {
|
|
559
|
+
return { ok: true, data: [] };
|
|
560
|
+
}
|
|
561
|
+
throw new Error("Network request failed");
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// ── Outbox: write operations ──────────────────────────────
|
|
565
|
+
async dispatchWrite(opType, method, path, body, query) {
|
|
566
|
+
const clientId = generateId();
|
|
567
|
+
const idempotencyKey = `sdk-${clientId}`;
|
|
568
|
+
let enrichedBody = body;
|
|
569
|
+
if (body && typeof body === "object" && (opType === "message.send" || opType === "message.edit")) {
|
|
570
|
+
enrichedBody = { ...body };
|
|
571
|
+
enrichedBody.metadata = {
|
|
572
|
+
...body.metadata,
|
|
573
|
+
_idempotencyKey: idempotencyKey
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
let localMessage;
|
|
577
|
+
if (opType === "message.send" && body && typeof body === "object") {
|
|
578
|
+
const b = body;
|
|
579
|
+
const convIdMatch = path.match(/\/(?:messages|direct|groups)\/([^/]+)/);
|
|
580
|
+
const conversationId = convIdMatch?.[1] ?? "";
|
|
581
|
+
localMessage = {
|
|
582
|
+
id: `local-${clientId}`,
|
|
583
|
+
clientId,
|
|
584
|
+
conversationId,
|
|
585
|
+
content: b.content ?? "",
|
|
586
|
+
type: b.type ?? "text",
|
|
587
|
+
senderId: "__self__",
|
|
588
|
+
parentId: b.parentId ?? null,
|
|
589
|
+
status: "pending",
|
|
590
|
+
metadata: b.metadata,
|
|
591
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
592
|
+
};
|
|
593
|
+
await this.storage.putMessages([localMessage]);
|
|
594
|
+
this.emit("message.local", localMessage);
|
|
595
|
+
}
|
|
596
|
+
const op = {
|
|
597
|
+
id: clientId,
|
|
598
|
+
type: opType,
|
|
599
|
+
method,
|
|
600
|
+
path,
|
|
601
|
+
body: enrichedBody,
|
|
602
|
+
query,
|
|
603
|
+
status: "pending",
|
|
604
|
+
createdAt: Date.now(),
|
|
605
|
+
retries: 0,
|
|
606
|
+
maxRetries: this.options.outboxRetryLimit,
|
|
607
|
+
idempotencyKey,
|
|
608
|
+
localData: localMessage
|
|
609
|
+
};
|
|
610
|
+
await this.storage.enqueue(op);
|
|
611
|
+
if (this._isOnline) this.flush();
|
|
612
|
+
const optimisticResult = {
|
|
613
|
+
ok: true,
|
|
614
|
+
data: localMessage ? { conversationId: localMessage.conversationId, message: localMessage } : void 0,
|
|
615
|
+
_pending: true,
|
|
616
|
+
_clientId: clientId
|
|
617
|
+
};
|
|
618
|
+
return optimisticResult;
|
|
619
|
+
}
|
|
620
|
+
// ── Outbox flush ──────────────────────────────────────────
|
|
621
|
+
startFlushTimer() {
|
|
622
|
+
this.stopFlushTimer();
|
|
623
|
+
this.flushTimer = setInterval(() => this.flush(), this.options.outboxFlushInterval);
|
|
624
|
+
}
|
|
625
|
+
stopFlushTimer() {
|
|
626
|
+
if (this.flushTimer) {
|
|
627
|
+
clearInterval(this.flushTimer);
|
|
628
|
+
this.flushTimer = null;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
async flush() {
|
|
632
|
+
if (this.flushing || !this._isOnline) return;
|
|
633
|
+
this.flushing = true;
|
|
634
|
+
try {
|
|
635
|
+
const ops = await this.storage.dequeueReady(10);
|
|
636
|
+
for (const op of ops) {
|
|
637
|
+
this.emit("outbox.sending", { opId: op.id, type: op.type });
|
|
638
|
+
try {
|
|
639
|
+
const result = await this.networkRequest(
|
|
640
|
+
op.method,
|
|
641
|
+
op.path,
|
|
642
|
+
op.body,
|
|
643
|
+
op.query
|
|
644
|
+
);
|
|
645
|
+
if (result.ok) {
|
|
646
|
+
await this.storage.ack(op.id);
|
|
647
|
+
this.emit("outbox.confirmed", { opId: op.id, serverData: result.data });
|
|
648
|
+
if (op.type === "message.send" && op.localData) {
|
|
649
|
+
const local = op.localData;
|
|
650
|
+
const serverMsg = result.data?.message;
|
|
651
|
+
if (serverMsg) {
|
|
652
|
+
await this.storage.deleteMessage(local.id);
|
|
653
|
+
await this.storage.putMessages([{
|
|
654
|
+
id: serverMsg.id,
|
|
655
|
+
clientId: op.id,
|
|
656
|
+
conversationId: serverMsg.conversationId ?? local.conversationId,
|
|
657
|
+
content: serverMsg.content ?? local.content,
|
|
658
|
+
type: serverMsg.type ?? local.type,
|
|
659
|
+
senderId: serverMsg.senderId ?? local.senderId,
|
|
660
|
+
parentId: serverMsg.parentId,
|
|
661
|
+
status: "confirmed",
|
|
662
|
+
metadata: serverMsg.metadata ? typeof serverMsg.metadata === "string" ? JSON.parse(serverMsg.metadata) : serverMsg.metadata : void 0,
|
|
663
|
+
createdAt: serverMsg.createdAt ?? local.createdAt
|
|
664
|
+
}]);
|
|
665
|
+
this.emit("message.confirmed", { clientId: op.id, serverMessage: serverMsg });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
} else {
|
|
669
|
+
const errCode = result.error?.code;
|
|
670
|
+
if (errCode && !errCode.includes("TIMEOUT") && !errCode.includes("NETWORK")) {
|
|
671
|
+
await this.storage.nack(op.id, result.error?.message ?? "Request failed", op.maxRetries);
|
|
672
|
+
this.emit("outbox.failed", { opId: op.id, error: result.error?.message ?? "Request failed", retriesLeft: 0 });
|
|
673
|
+
if (op.type === "message.send") {
|
|
674
|
+
this.emit("message.failed", { clientId: op.id, error: result.error?.message ?? "Request failed" });
|
|
675
|
+
}
|
|
676
|
+
} else {
|
|
677
|
+
await this.storage.nack(op.id, result.error?.message ?? "Transient error", op.retries + 1);
|
|
678
|
+
this.emit("outbox.failed", {
|
|
679
|
+
opId: op.id,
|
|
680
|
+
error: result.error?.message ?? "Transient error",
|
|
681
|
+
retriesLeft: op.maxRetries - op.retries - 1
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
} catch (err) {
|
|
686
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
687
|
+
await this.storage.nack(op.id, msg, op.retries + 1);
|
|
688
|
+
if (op.retries + 1 >= op.maxRetries) {
|
|
689
|
+
this.emit("outbox.failed", { opId: op.id, error: msg, retriesLeft: 0 });
|
|
690
|
+
if (op.type === "message.send") {
|
|
691
|
+
this.emit("message.failed", { clientId: op.id, error: msg });
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} finally {
|
|
697
|
+
this.flushing = false;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
get outboxSize() {
|
|
701
|
+
return this.storage.getPendingCount();
|
|
702
|
+
}
|
|
703
|
+
// ── Sync engine ───────────────────────────────────────────
|
|
704
|
+
async sync() {
|
|
705
|
+
if (this._syncState === "syncing" || !this._isOnline) return;
|
|
706
|
+
this._syncState = "syncing";
|
|
707
|
+
this.emit("sync.start", void 0);
|
|
708
|
+
let totalNew = 0;
|
|
709
|
+
let totalUpdated = 0;
|
|
710
|
+
try {
|
|
711
|
+
let cursor = await this.storage.getCursor("global_sync") ?? "0";
|
|
712
|
+
let hasMore = true;
|
|
713
|
+
while (hasMore) {
|
|
714
|
+
const result = await this.networkRequest(
|
|
715
|
+
"GET",
|
|
716
|
+
"/api/im/sync",
|
|
717
|
+
void 0,
|
|
718
|
+
{ since: cursor, limit: "100" }
|
|
719
|
+
);
|
|
720
|
+
if (!result.ok || !result.data) {
|
|
721
|
+
throw new Error(result.error?.message ?? "Sync failed");
|
|
722
|
+
}
|
|
723
|
+
const { events, cursor: newCursor, hasMore: more } = result.data;
|
|
724
|
+
for (const event of events) {
|
|
725
|
+
await this.applySyncEvent(event);
|
|
726
|
+
if (event.type === "message.new") totalNew++;
|
|
727
|
+
if (event.type.startsWith("conversation.")) totalUpdated++;
|
|
728
|
+
}
|
|
729
|
+
cursor = String(newCursor);
|
|
730
|
+
await this.storage.setCursor("global_sync", cursor);
|
|
731
|
+
hasMore = more;
|
|
732
|
+
this.emit("sync.progress", { synced: events.length, total: events.length });
|
|
733
|
+
}
|
|
734
|
+
this._syncState = "idle";
|
|
735
|
+
this.emit("sync.complete", { newMessages: totalNew, updatedConversations: totalUpdated });
|
|
736
|
+
} catch (err) {
|
|
737
|
+
this._syncState = "error";
|
|
738
|
+
this.emit("sync.error", {
|
|
739
|
+
error: err instanceof Error ? err.message : "Sync failed",
|
|
740
|
+
willRetry: false
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
async applySyncEvent(event) {
|
|
745
|
+
switch (event.type) {
|
|
746
|
+
case "message.new": {
|
|
747
|
+
const msg = event.data;
|
|
748
|
+
await this.storage.putMessages([{
|
|
749
|
+
id: msg.id,
|
|
750
|
+
conversationId: msg.conversationId ?? event.conversationId ?? "",
|
|
751
|
+
content: msg.content ?? "",
|
|
752
|
+
type: msg.type ?? "text",
|
|
753
|
+
senderId: msg.senderId ?? "",
|
|
754
|
+
parentId: msg.parentId ?? null,
|
|
755
|
+
status: "confirmed",
|
|
756
|
+
metadata: msg.metadata,
|
|
757
|
+
createdAt: msg.createdAt ?? event.at,
|
|
758
|
+
syncSeq: event.seq
|
|
759
|
+
}]);
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
case "message.edit": {
|
|
763
|
+
const existing = await this.storage.getMessage(event.data.id);
|
|
764
|
+
if (existing) {
|
|
765
|
+
const hasLocalEdits = existing.status !== "confirmed";
|
|
766
|
+
if (hasLocalEdits && this.options.onConflict) {
|
|
767
|
+
const resolution = this.options.onConflict(existing, event);
|
|
768
|
+
if (resolution === "keep_local") break;
|
|
769
|
+
if (resolution !== "accept_remote" && typeof resolution === "object") {
|
|
770
|
+
resolution.syncSeq = event.seq;
|
|
771
|
+
await this.storage.putMessages([resolution]);
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
existing.content = event.data.content ?? existing.content;
|
|
776
|
+
existing.updatedAt = event.at;
|
|
777
|
+
existing.syncSeq = event.seq;
|
|
778
|
+
await this.storage.putMessages([existing]);
|
|
779
|
+
}
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
case "message.delete": {
|
|
783
|
+
if (event.data?.id) await this.storage.deleteMessage(event.data.id);
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
case "conversation.create":
|
|
787
|
+
case "conversation.update": {
|
|
788
|
+
const conv = event.data;
|
|
789
|
+
await this.storage.putConversations([{
|
|
790
|
+
id: conv.id ?? event.conversationId ?? "",
|
|
791
|
+
type: conv.type ?? "direct",
|
|
792
|
+
title: conv.title,
|
|
793
|
+
unreadCount: conv.unreadCount ?? 0,
|
|
794
|
+
members: conv.members,
|
|
795
|
+
metadata: conv.metadata,
|
|
796
|
+
syncSeq: event.seq,
|
|
797
|
+
updatedAt: event.at,
|
|
798
|
+
lastMessageAt: conv.lastMessageAt
|
|
799
|
+
}]);
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
case "conversation.archive": {
|
|
803
|
+
const convId = event.data?.id ?? event.conversationId;
|
|
804
|
+
if (convId) {
|
|
805
|
+
const existing = await this.storage.getConversation(convId);
|
|
806
|
+
if (existing) {
|
|
807
|
+
existing.metadata = { ...existing.metadata, _archived: true };
|
|
808
|
+
existing.syncSeq = event.seq;
|
|
809
|
+
existing.updatedAt = event.at;
|
|
810
|
+
await this.storage.putConversations([existing]);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
case "participant.add": {
|
|
816
|
+
const convId = event.data?.conversationId ?? event.conversationId;
|
|
817
|
+
if (convId) {
|
|
818
|
+
const existing = await this.storage.getConversation(convId);
|
|
819
|
+
if (existing && existing.members) {
|
|
820
|
+
const already = existing.members.find((m) => m.userId === event.data.userId);
|
|
821
|
+
if (!already) {
|
|
822
|
+
existing.members.push({
|
|
823
|
+
userId: event.data.userId,
|
|
824
|
+
username: event.data.username ?? "",
|
|
825
|
+
displayName: event.data.displayName,
|
|
826
|
+
role: event.data.role ?? "member"
|
|
827
|
+
});
|
|
828
|
+
existing.syncSeq = event.seq;
|
|
829
|
+
existing.updatedAt = event.at;
|
|
830
|
+
await this.storage.putConversations([existing]);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
case "participant.remove": {
|
|
837
|
+
const convId = event.data?.conversationId ?? event.conversationId;
|
|
838
|
+
if (convId) {
|
|
839
|
+
const existing = await this.storage.getConversation(convId);
|
|
840
|
+
if (existing && existing.members) {
|
|
841
|
+
existing.members = existing.members.filter((m) => m.userId !== event.data.userId);
|
|
842
|
+
existing.syncSeq = event.seq;
|
|
843
|
+
existing.updatedAt = event.at;
|
|
844
|
+
await this.storage.putConversations([existing]);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Handle a realtime event (from WS/SSE) and store locally.
|
|
853
|
+
*/
|
|
854
|
+
async handleRealtimeEvent(type, payload) {
|
|
855
|
+
if (type === "message.new" && payload) {
|
|
856
|
+
await this.storage.putMessages([{
|
|
857
|
+
id: payload.id,
|
|
858
|
+
conversationId: payload.conversationId ?? "",
|
|
859
|
+
content: payload.content ?? "",
|
|
860
|
+
type: payload.type ?? "text",
|
|
861
|
+
senderId: payload.senderId ?? "",
|
|
862
|
+
parentId: payload.parentId ?? null,
|
|
863
|
+
status: "confirmed",
|
|
864
|
+
metadata: payload.metadata,
|
|
865
|
+
createdAt: payload.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
866
|
+
}]);
|
|
867
|
+
}
|
|
868
|
+
if (type === "presence.changed" && payload?.userId) {
|
|
869
|
+
this.presenceCache.set(payload.userId, {
|
|
870
|
+
status: payload.status ?? "offline",
|
|
871
|
+
lastSeen: payload.lastSeen ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
872
|
+
});
|
|
873
|
+
this.emit("presence.changed", payload);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Get cached presence status for a user.
|
|
878
|
+
*/
|
|
879
|
+
getPresence(userId) {
|
|
880
|
+
return this.presenceCache.get(userId) ?? null;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Search messages in local storage.
|
|
884
|
+
*/
|
|
885
|
+
async searchMessages(query, opts) {
|
|
886
|
+
if (this.storage.searchMessages) {
|
|
887
|
+
return this.storage.searchMessages(query, opts);
|
|
888
|
+
}
|
|
889
|
+
return [];
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Get storage size and quota info.
|
|
893
|
+
*/
|
|
894
|
+
async getQuotaStatus() {
|
|
895
|
+
const limit = this.options.quota?.maxStorageBytes ?? 500 * 1024 * 1024;
|
|
896
|
+
const threshold = this.options.quota?.warningThreshold ?? 0.9;
|
|
897
|
+
if (this.storage.getStorageSize) {
|
|
898
|
+
const size = await this.storage.getStorageSize();
|
|
899
|
+
const percentage = size.total / limit;
|
|
900
|
+
return {
|
|
901
|
+
used: size.total,
|
|
902
|
+
limit,
|
|
903
|
+
percentage,
|
|
904
|
+
warning: percentage >= threshold,
|
|
905
|
+
exceeded: percentage >= 1
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
return { used: 0, limit, percentage: 0, warning: false, exceeded: false };
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Clear old messages for a conversation (user-initiated quota management).
|
|
912
|
+
*/
|
|
913
|
+
async clearOldMessages(conversationId, keepCount) {
|
|
914
|
+
if (this.storage.clearOldMessages) {
|
|
915
|
+
return this.storage.clearOldMessages(conversationId, keepCount);
|
|
916
|
+
}
|
|
917
|
+
return 0;
|
|
918
|
+
}
|
|
919
|
+
// ── Read cache ────────────────────────────────────────────
|
|
920
|
+
async readFromCache(path, query) {
|
|
921
|
+
if (/\/api\/im\/conversations$/.test(path)) {
|
|
922
|
+
const convos = await this.storage.getConversations({ limit: 50 });
|
|
923
|
+
if (convos.length > 0) return { ok: true, data: convos };
|
|
924
|
+
}
|
|
925
|
+
const msgMatch = path.match(/\/api\/im\/messages\/([^/]+)$/);
|
|
926
|
+
if (msgMatch) {
|
|
927
|
+
const convId = msgMatch[1];
|
|
928
|
+
const limit = query?.limit ? parseInt(query.limit) : 50;
|
|
929
|
+
const messages = await this.storage.getMessages(convId, { limit, before: query?.before });
|
|
930
|
+
if (messages.length > 0) return { ok: true, data: messages };
|
|
931
|
+
}
|
|
932
|
+
if (/\/api\/im\/contacts$/.test(path)) {
|
|
933
|
+
const contacts = await this.storage.getContacts();
|
|
934
|
+
if (contacts.length > 0) return { ok: true, data: contacts };
|
|
935
|
+
}
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
async cacheReadResult(path, _query, result) {
|
|
939
|
+
if (!result?.ok || !result?.data) return;
|
|
940
|
+
try {
|
|
941
|
+
if (/\/api\/im\/conversations$/.test(path) && Array.isArray(result.data)) {
|
|
942
|
+
const convos = result.data.map((c) => ({
|
|
943
|
+
id: c.id,
|
|
944
|
+
type: c.type ?? "direct",
|
|
945
|
+
title: c.title,
|
|
946
|
+
lastMessage: c.lastMessage,
|
|
947
|
+
lastMessageAt: c.lastMessageAt ?? c.updatedAt,
|
|
948
|
+
unreadCount: c.unreadCount ?? 0,
|
|
949
|
+
members: c.members,
|
|
950
|
+
metadata: c.metadata,
|
|
951
|
+
updatedAt: c.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
952
|
+
}));
|
|
953
|
+
await this.storage.putConversations(convos);
|
|
954
|
+
}
|
|
955
|
+
const msgMatch = path.match(/\/api\/im\/messages\/([^/]+)$/);
|
|
956
|
+
if (msgMatch && Array.isArray(result.data)) {
|
|
957
|
+
const messages = result.data.map((m) => ({
|
|
958
|
+
id: m.id,
|
|
959
|
+
conversationId: m.conversationId ?? msgMatch[1],
|
|
960
|
+
content: m.content ?? "",
|
|
961
|
+
type: m.type ?? "text",
|
|
962
|
+
senderId: m.senderId ?? "",
|
|
963
|
+
parentId: m.parentId ?? null,
|
|
964
|
+
status: "confirmed",
|
|
965
|
+
metadata: m.metadata,
|
|
966
|
+
createdAt: m.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
967
|
+
}));
|
|
968
|
+
await this.storage.putMessages(messages);
|
|
969
|
+
}
|
|
970
|
+
if (/\/api\/im\/contacts$/.test(path) && Array.isArray(result.data)) {
|
|
971
|
+
await this.storage.putContacts(result.data);
|
|
972
|
+
}
|
|
973
|
+
} catch {
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
// ── SSE continuous sync ────────────────────────────────────
|
|
977
|
+
/**
|
|
978
|
+
* Start continuous sync via SSE (Server-Sent Events).
|
|
979
|
+
* Replaces polling with real-time push when syncMode is 'push'.
|
|
980
|
+
*/
|
|
981
|
+
async startContinuousSync() {
|
|
982
|
+
if (this.sseSource) return;
|
|
983
|
+
if (typeof EventSource === "undefined") {
|
|
984
|
+
return this.sync();
|
|
985
|
+
}
|
|
986
|
+
const token = this.tokenProvider?.();
|
|
987
|
+
if (!token) {
|
|
988
|
+
return this.sync();
|
|
989
|
+
}
|
|
990
|
+
const cursor = await this.storage.getCursor("global_sync") ?? "0";
|
|
991
|
+
const baseUrl = this.getBaseUrl();
|
|
992
|
+
const url = `${baseUrl}/api/im/sync/stream?token=${encodeURIComponent(token)}&since=${cursor}`;
|
|
993
|
+
this._syncState = "syncing";
|
|
994
|
+
this.emit("sync.start", void 0);
|
|
995
|
+
this.sseReconnectAttempts = 0;
|
|
996
|
+
try {
|
|
997
|
+
this.sseSource = new EventSource(url);
|
|
998
|
+
let totalNew = 0;
|
|
999
|
+
let totalUpdated = 0;
|
|
1000
|
+
this.sseSource.addEventListener("sync", async (e) => {
|
|
1001
|
+
try {
|
|
1002
|
+
const event = JSON.parse(e.data);
|
|
1003
|
+
await this.applySyncEvent(event);
|
|
1004
|
+
await this.storage.setCursor("global_sync", String(event.seq));
|
|
1005
|
+
if (event.type === "message.new") totalNew++;
|
|
1006
|
+
if (event.type.startsWith("conversation.")) totalUpdated++;
|
|
1007
|
+
this.emit("sync.progress", { synced: 1, total: 1 });
|
|
1008
|
+
if (this.options.quota) {
|
|
1009
|
+
await this.checkQuota();
|
|
1010
|
+
}
|
|
1011
|
+
} catch {
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
this.sseSource.addEventListener("caught_up", () => {
|
|
1015
|
+
this._syncState = "idle";
|
|
1016
|
+
this.sseReconnectAttempts = 0;
|
|
1017
|
+
this.emit("sync.complete", { newMessages: totalNew, updatedConversations: totalUpdated });
|
|
1018
|
+
totalNew = 0;
|
|
1019
|
+
totalUpdated = 0;
|
|
1020
|
+
});
|
|
1021
|
+
this.sseSource.addEventListener("error", () => {
|
|
1022
|
+
this._syncState = "error";
|
|
1023
|
+
this.emit("sync.error", { error: "SSE connection error", willRetry: true });
|
|
1024
|
+
});
|
|
1025
|
+
this.sseSource.onerror = () => {
|
|
1026
|
+
if (this.sseSource?.readyState === EventSource.CLOSED) {
|
|
1027
|
+
this.sseSource = null;
|
|
1028
|
+
this._syncState = "error";
|
|
1029
|
+
this.scheduleSseReconnect();
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
this._syncState = "error";
|
|
1034
|
+
this.emit("sync.error", {
|
|
1035
|
+
error: err instanceof Error ? err.message : "SSE init failed",
|
|
1036
|
+
willRetry: true
|
|
1037
|
+
});
|
|
1038
|
+
this.scheduleSseReconnect();
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Stop the SSE continuous sync connection.
|
|
1043
|
+
*/
|
|
1044
|
+
stopContinuousSync() {
|
|
1045
|
+
if (this.sseSource) {
|
|
1046
|
+
this.sseSource.close();
|
|
1047
|
+
this.sseSource = null;
|
|
1048
|
+
}
|
|
1049
|
+
if (this.sseReconnectTimer) {
|
|
1050
|
+
clearTimeout(this.sseReconnectTimer);
|
|
1051
|
+
this.sseReconnectTimer = null;
|
|
1052
|
+
}
|
|
1053
|
+
this._syncState = "idle";
|
|
1054
|
+
}
|
|
1055
|
+
scheduleSseReconnect() {
|
|
1056
|
+
if (!this._isOnline) return;
|
|
1057
|
+
this.sseReconnectAttempts++;
|
|
1058
|
+
const delay = Math.min(1e3 * Math.pow(2, this.sseReconnectAttempts - 1), 3e4);
|
|
1059
|
+
this.sseReconnectTimer = setTimeout(() => {
|
|
1060
|
+
this.sseReconnectTimer = null;
|
|
1061
|
+
if (this._isOnline) this.startContinuousSync();
|
|
1062
|
+
}, delay);
|
|
1063
|
+
}
|
|
1064
|
+
/** Get the base URL for SSE connections (strip /api/im prefix). */
|
|
1065
|
+
getBaseUrl() {
|
|
1066
|
+
return typeof window !== "undefined" ? window.location.origin : "http://localhost:3000";
|
|
1067
|
+
}
|
|
1068
|
+
// ── Quota check ─────────────────────────────────────────────
|
|
1069
|
+
async checkQuota() {
|
|
1070
|
+
if (!this.options.quota || !this.storage.getStorageSize) return;
|
|
1071
|
+
const size = await this.storage.getStorageSize();
|
|
1072
|
+
const limit = this.options.quota.maxStorageBytes;
|
|
1073
|
+
const threshold = this.options.quota.warningThreshold;
|
|
1074
|
+
const pct = size.total / limit;
|
|
1075
|
+
if (pct >= 1) {
|
|
1076
|
+
this.emit("quota.exceeded", { used: size.total, limit });
|
|
1077
|
+
} else if (pct >= threshold) {
|
|
1078
|
+
this.emit("quota.warning", { used: size.total, limit, percentage: pct });
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
var AttachmentQueue = class {
|
|
1083
|
+
constructor(offline, networkRequest) {
|
|
1084
|
+
this.offline = offline;
|
|
1085
|
+
this.networkRequest = networkRequest;
|
|
1086
|
+
this.queue = /* @__PURE__ */ new Map();
|
|
1087
|
+
this.uploading = false;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Queue a file attachment for offline upload.
|
|
1091
|
+
* Returns the queued attachment with a local ID.
|
|
1092
|
+
*/
|
|
1093
|
+
async queueAttachment(conversationId, file, messageContent) {
|
|
1094
|
+
const id = generateId();
|
|
1095
|
+
const attachment = {
|
|
1096
|
+
id,
|
|
1097
|
+
conversationId,
|
|
1098
|
+
file: { name: file.name, size: file.size, type: file.type },
|
|
1099
|
+
data: file.data,
|
|
1100
|
+
status: "pending",
|
|
1101
|
+
progress: 0,
|
|
1102
|
+
messageClientId: generateId(),
|
|
1103
|
+
createdAt: Date.now()
|
|
1104
|
+
};
|
|
1105
|
+
this.queue.set(id, attachment);
|
|
1106
|
+
await this.offline.storage.putMessages([{
|
|
1107
|
+
id: `local-${attachment.messageClientId}`,
|
|
1108
|
+
clientId: attachment.messageClientId,
|
|
1109
|
+
conversationId,
|
|
1110
|
+
content: messageContent ?? `[File: ${file.name}]`,
|
|
1111
|
+
type: "file",
|
|
1112
|
+
senderId: "__self__",
|
|
1113
|
+
status: "pending",
|
|
1114
|
+
metadata: { _attachmentId: id, fileName: file.name, fileSize: file.size },
|
|
1115
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1116
|
+
}]);
|
|
1117
|
+
if (this.offline.isOnline) this.processQueue();
|
|
1118
|
+
return attachment;
|
|
1119
|
+
}
|
|
1120
|
+
/** Process pending uploads. */
|
|
1121
|
+
async processQueue() {
|
|
1122
|
+
if (this.uploading || !this.offline.isOnline) return;
|
|
1123
|
+
this.uploading = true;
|
|
1124
|
+
try {
|
|
1125
|
+
for (const [id, att] of this.queue) {
|
|
1126
|
+
if (att.status !== "pending") continue;
|
|
1127
|
+
att.status = "uploading";
|
|
1128
|
+
try {
|
|
1129
|
+
const presign = await this.networkRequest(
|
|
1130
|
+
"POST",
|
|
1131
|
+
"/api/im/files/presign",
|
|
1132
|
+
{ fileName: att.file.name, fileSize: att.file.size, mimeType: att.file.type }
|
|
1133
|
+
);
|
|
1134
|
+
if (!presign.ok || !presign.data?.uploadUrl) {
|
|
1135
|
+
throw new Error(presign.error?.message ?? "Presign failed");
|
|
1136
|
+
}
|
|
1137
|
+
if (att.data) {
|
|
1138
|
+
await fetch(presign.data.uploadUrl, {
|
|
1139
|
+
method: "PUT",
|
|
1140
|
+
body: att.data,
|
|
1141
|
+
headers: { "Content-Type": att.file.type }
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
att.progress = 80;
|
|
1145
|
+
const confirm = await this.networkRequest(
|
|
1146
|
+
"POST",
|
|
1147
|
+
"/api/im/files/confirm",
|
|
1148
|
+
{ uploadId: presign.data.uploadId }
|
|
1149
|
+
);
|
|
1150
|
+
if (!confirm.ok) {
|
|
1151
|
+
throw new Error(confirm.error?.message ?? "Confirm failed");
|
|
1152
|
+
}
|
|
1153
|
+
att.status = "uploaded";
|
|
1154
|
+
att.progress = 100;
|
|
1155
|
+
await this.networkRequest(
|
|
1156
|
+
"POST",
|
|
1157
|
+
`/api/im/messages/${att.conversationId}`,
|
|
1158
|
+
{
|
|
1159
|
+
type: "file",
|
|
1160
|
+
content: `[File: ${att.file.name}]`,
|
|
1161
|
+
metadata: {
|
|
1162
|
+
fileUrl: confirm.data?.url ?? presign.data.downloadUrl,
|
|
1163
|
+
fileName: att.file.name,
|
|
1164
|
+
fileSize: att.file.size,
|
|
1165
|
+
mimeType: att.file.type,
|
|
1166
|
+
uploadId: presign.data.uploadId
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
);
|
|
1170
|
+
await this.offline.storage.deleteMessage(`local-${att.messageClientId}`);
|
|
1171
|
+
this.queue.delete(id);
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
att.status = "failed";
|
|
1174
|
+
att.error = err instanceof Error ? err.message : "Upload failed";
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
} finally {
|
|
1178
|
+
this.uploading = false;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
/** Get all queued attachments. */
|
|
1182
|
+
getQueue() {
|
|
1183
|
+
return Array.from(this.queue.values());
|
|
1184
|
+
}
|
|
1185
|
+
/** Retry a failed attachment upload. */
|
|
1186
|
+
async retry(attachmentId) {
|
|
1187
|
+
const att = this.queue.get(attachmentId);
|
|
1188
|
+
if (att && att.status === "failed") {
|
|
1189
|
+
att.status = "pending";
|
|
1190
|
+
att.error = void 0;
|
|
1191
|
+
if (this.offline.isOnline) this.processQueue();
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
/** Cancel and remove a queued attachment. */
|
|
1195
|
+
async cancel(attachmentId) {
|
|
1196
|
+
const att = this.queue.get(attachmentId);
|
|
1197
|
+
if (att) {
|
|
1198
|
+
await this.offline.storage.deleteMessage(`local-${att.messageClientId}`);
|
|
1199
|
+
this.queue.delete(attachmentId);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
|
|
428
1204
|
// src/types.ts
|
|
429
1205
|
var ENVIRONMENTS = {
|
|
430
1206
|
production: "https://prismer.cloud"
|
|
431
1207
|
};
|
|
432
1208
|
|
|
1209
|
+
// src/storage.ts
|
|
1210
|
+
var MemoryStorage = class {
|
|
1211
|
+
constructor() {
|
|
1212
|
+
this.messages = /* @__PURE__ */ new Map();
|
|
1213
|
+
this.conversations = /* @__PURE__ */ new Map();
|
|
1214
|
+
this.contacts = /* @__PURE__ */ new Map();
|
|
1215
|
+
this.cursors = /* @__PURE__ */ new Map();
|
|
1216
|
+
this.outbox = /* @__PURE__ */ new Map();
|
|
1217
|
+
}
|
|
1218
|
+
async init() {
|
|
1219
|
+
}
|
|
1220
|
+
// ── Messages ────────────────────────────────────────────────
|
|
1221
|
+
async putMessages(messages) {
|
|
1222
|
+
for (const m of messages) this.messages.set(m.id, { ...m });
|
|
1223
|
+
}
|
|
1224
|
+
async getMessages(conversationId, opts) {
|
|
1225
|
+
const all = Array.from(this.messages.values()).filter((m) => m.conversationId === conversationId).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
1226
|
+
if (opts.before) {
|
|
1227
|
+
const idx = all.findIndex((m) => m.id === opts.before);
|
|
1228
|
+
if (idx > 0) return all.slice(Math.max(0, idx - opts.limit), idx);
|
|
1229
|
+
}
|
|
1230
|
+
return all.slice(-opts.limit);
|
|
1231
|
+
}
|
|
1232
|
+
async getMessage(messageId) {
|
|
1233
|
+
return this.messages.get(messageId) ?? null;
|
|
1234
|
+
}
|
|
1235
|
+
async deleteMessage(messageId) {
|
|
1236
|
+
this.messages.delete(messageId);
|
|
1237
|
+
}
|
|
1238
|
+
// ── Conversations ───────────────────────────────────────────
|
|
1239
|
+
async putConversations(conversations) {
|
|
1240
|
+
for (const c of conversations) this.conversations.set(c.id, { ...c });
|
|
1241
|
+
}
|
|
1242
|
+
async getConversations(opts) {
|
|
1243
|
+
const all = Array.from(this.conversations.values()).sort((a, b) => (b.lastMessageAt ?? b.updatedAt).localeCompare(a.lastMessageAt ?? a.updatedAt));
|
|
1244
|
+
const offset = opts?.offset ?? 0;
|
|
1245
|
+
const limit = opts?.limit ?? 50;
|
|
1246
|
+
return all.slice(offset, offset + limit);
|
|
1247
|
+
}
|
|
1248
|
+
async getConversation(id) {
|
|
1249
|
+
return this.conversations.get(id) ?? null;
|
|
1250
|
+
}
|
|
1251
|
+
// ── Contacts ────────────────────────────────────────────────
|
|
1252
|
+
async putContacts(contacts) {
|
|
1253
|
+
for (const c of contacts) this.contacts.set(c.userId, { ...c });
|
|
1254
|
+
}
|
|
1255
|
+
async getContacts() {
|
|
1256
|
+
return Array.from(this.contacts.values());
|
|
1257
|
+
}
|
|
1258
|
+
// ── Cursors ─────────────────────────────────────────────────
|
|
1259
|
+
async getCursor(key) {
|
|
1260
|
+
return this.cursors.get(key) ?? null;
|
|
1261
|
+
}
|
|
1262
|
+
async setCursor(key, value) {
|
|
1263
|
+
this.cursors.set(key, value);
|
|
1264
|
+
}
|
|
1265
|
+
// ── Outbox ──────────────────────────────────────────────────
|
|
1266
|
+
async enqueue(op) {
|
|
1267
|
+
this.outbox.set(op.id, { ...op });
|
|
1268
|
+
}
|
|
1269
|
+
async dequeueReady(limit) {
|
|
1270
|
+
const ready = Array.from(this.outbox.values()).filter((op) => op.status === "pending").sort((a, b) => a.createdAt - b.createdAt).slice(0, limit);
|
|
1271
|
+
for (const op of ready) {
|
|
1272
|
+
op.status = "inflight";
|
|
1273
|
+
this.outbox.set(op.id, op);
|
|
1274
|
+
}
|
|
1275
|
+
return ready;
|
|
1276
|
+
}
|
|
1277
|
+
async ack(opId) {
|
|
1278
|
+
this.outbox.delete(opId);
|
|
1279
|
+
}
|
|
1280
|
+
async nack(opId, error, retries) {
|
|
1281
|
+
const op = this.outbox.get(opId);
|
|
1282
|
+
if (!op) return;
|
|
1283
|
+
op.retries = retries;
|
|
1284
|
+
op.lastError = error;
|
|
1285
|
+
op.status = retries >= op.maxRetries ? "failed" : "pending";
|
|
1286
|
+
this.outbox.set(opId, op);
|
|
1287
|
+
}
|
|
1288
|
+
async getPendingCount() {
|
|
1289
|
+
return Array.from(this.outbox.values()).filter((op) => op.status === "pending" || op.status === "inflight").length;
|
|
1290
|
+
}
|
|
1291
|
+
// ── Search ─────────────────────────────────────────────────
|
|
1292
|
+
async searchMessages(query, opts) {
|
|
1293
|
+
const lower = query.toLowerCase();
|
|
1294
|
+
const limit = opts?.limit ?? 50;
|
|
1295
|
+
return Array.from(this.messages.values()).filter((m) => {
|
|
1296
|
+
if (opts?.conversationId && m.conversationId !== opts.conversationId) return false;
|
|
1297
|
+
return (m.content ?? "").toLowerCase().includes(lower);
|
|
1298
|
+
}).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit);
|
|
1299
|
+
}
|
|
1300
|
+
// ── Quota ─────────────────────────────────────────────────
|
|
1301
|
+
async getStorageSize() {
|
|
1302
|
+
const msgSize = this.messages.size * 500;
|
|
1303
|
+
const convSize = this.conversations.size * 200;
|
|
1304
|
+
return { messages: msgSize, conversations: convSize, total: msgSize + convSize };
|
|
1305
|
+
}
|
|
1306
|
+
async clearOldMessages(conversationId, keepCount) {
|
|
1307
|
+
const msgs = Array.from(this.messages.values()).filter((m) => m.conversationId === conversationId).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
1308
|
+
const toDelete = msgs.slice(keepCount);
|
|
1309
|
+
for (const m of toDelete) this.messages.delete(m.id);
|
|
1310
|
+
return toDelete.length;
|
|
1311
|
+
}
|
|
1312
|
+
// ── Lifecycle ───────────────────────────────────────────────
|
|
1313
|
+
async clear() {
|
|
1314
|
+
this.messages.clear();
|
|
1315
|
+
this.conversations.clear();
|
|
1316
|
+
this.contacts.clear();
|
|
1317
|
+
this.cursors.clear();
|
|
1318
|
+
this.outbox.clear();
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
var IDB_STORES = ["messages", "conversations", "contacts", "cursors", "outbox"];
|
|
1322
|
+
var IndexedDBStorage = class {
|
|
1323
|
+
constructor(dbName = "prismer-offline", version = 1) {
|
|
1324
|
+
this.dbName = dbName;
|
|
1325
|
+
this.version = version;
|
|
1326
|
+
this.db = null;
|
|
1327
|
+
}
|
|
1328
|
+
async init() {
|
|
1329
|
+
if (typeof indexedDB === "undefined") {
|
|
1330
|
+
throw new Error("IndexedDB is not available in this environment. Use MemoryStorage or SQLiteStorage instead.");
|
|
1331
|
+
}
|
|
1332
|
+
return new Promise((resolve, reject) => {
|
|
1333
|
+
const req = indexedDB.open(this.dbName, this.version);
|
|
1334
|
+
req.onupgradeneeded = () => {
|
|
1335
|
+
const db = req.result;
|
|
1336
|
+
if (!db.objectStoreNames.contains("messages")) {
|
|
1337
|
+
const store = db.createObjectStore("messages", { keyPath: "id" });
|
|
1338
|
+
store.createIndex("conversationId", "conversationId", { unique: false });
|
|
1339
|
+
store.createIndex("createdAt", "createdAt", { unique: false });
|
|
1340
|
+
}
|
|
1341
|
+
if (!db.objectStoreNames.contains("conversations")) {
|
|
1342
|
+
db.createObjectStore("conversations", { keyPath: "id" });
|
|
1343
|
+
}
|
|
1344
|
+
if (!db.objectStoreNames.contains("contacts")) {
|
|
1345
|
+
db.createObjectStore("contacts", { keyPath: "userId" });
|
|
1346
|
+
}
|
|
1347
|
+
if (!db.objectStoreNames.contains("cursors")) {
|
|
1348
|
+
db.createObjectStore("cursors", { keyPath: "key" });
|
|
1349
|
+
}
|
|
1350
|
+
if (!db.objectStoreNames.contains("outbox")) {
|
|
1351
|
+
const store = db.createObjectStore("outbox", { keyPath: "id" });
|
|
1352
|
+
store.createIndex("status", "status", { unique: false });
|
|
1353
|
+
store.createIndex("createdAt", "createdAt", { unique: false });
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
req.onsuccess = () => {
|
|
1357
|
+
this.db = req.result;
|
|
1358
|
+
resolve();
|
|
1359
|
+
};
|
|
1360
|
+
req.onerror = () => reject(req.error);
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
tx(stores, mode = "readonly") {
|
|
1364
|
+
if (!this.db) throw new Error("IndexedDB not initialized. Call init() first.");
|
|
1365
|
+
return this.db.transaction(stores, mode);
|
|
1366
|
+
}
|
|
1367
|
+
req(request) {
|
|
1368
|
+
return new Promise((resolve, reject) => {
|
|
1369
|
+
request.onsuccess = () => resolve(request.result);
|
|
1370
|
+
request.onerror = () => reject(request.error);
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
// ── Messages ────────────────────────────────────────────────
|
|
1374
|
+
async putMessages(messages) {
|
|
1375
|
+
const tx = this.tx("messages", "readwrite");
|
|
1376
|
+
const store = tx.objectStore("messages");
|
|
1377
|
+
for (const m of messages) store.put(m);
|
|
1378
|
+
return new Promise((resolve, reject) => {
|
|
1379
|
+
tx.oncomplete = () => resolve();
|
|
1380
|
+
tx.onerror = () => reject(tx.error);
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
async getMessages(conversationId, opts) {
|
|
1384
|
+
const tx = this.tx("messages");
|
|
1385
|
+
const store = tx.objectStore("messages");
|
|
1386
|
+
const idx = store.index("conversationId");
|
|
1387
|
+
const all = await this.req(idx.getAll(conversationId));
|
|
1388
|
+
all.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
1389
|
+
if (opts.before) {
|
|
1390
|
+
const i = all.findIndex((m) => m.id === opts.before);
|
|
1391
|
+
if (i > 0) return all.slice(Math.max(0, i - opts.limit), i);
|
|
1392
|
+
}
|
|
1393
|
+
return all.slice(-opts.limit);
|
|
1394
|
+
}
|
|
1395
|
+
async getMessage(messageId) {
|
|
1396
|
+
const tx = this.tx("messages");
|
|
1397
|
+
const result = await this.req(tx.objectStore("messages").get(messageId));
|
|
1398
|
+
return result ?? null;
|
|
1399
|
+
}
|
|
1400
|
+
async deleteMessage(messageId) {
|
|
1401
|
+
const tx = this.tx("messages", "readwrite");
|
|
1402
|
+
tx.objectStore("messages").delete(messageId);
|
|
1403
|
+
return new Promise((resolve, reject) => {
|
|
1404
|
+
tx.oncomplete = () => resolve();
|
|
1405
|
+
tx.onerror = () => reject(tx.error);
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
// ── Conversations ───────────────────────────────────────────
|
|
1409
|
+
async putConversations(conversations) {
|
|
1410
|
+
const tx = this.tx("conversations", "readwrite");
|
|
1411
|
+
const store = tx.objectStore("conversations");
|
|
1412
|
+
for (const c of conversations) store.put(c);
|
|
1413
|
+
return new Promise((resolve, reject) => {
|
|
1414
|
+
tx.oncomplete = () => resolve();
|
|
1415
|
+
tx.onerror = () => reject(tx.error);
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
async getConversations(opts) {
|
|
1419
|
+
const tx = this.tx("conversations");
|
|
1420
|
+
const all = await this.req(tx.objectStore("conversations").getAll());
|
|
1421
|
+
all.sort((a, b) => (b.lastMessageAt ?? b.updatedAt).localeCompare(a.lastMessageAt ?? a.updatedAt));
|
|
1422
|
+
const offset = opts?.offset ?? 0;
|
|
1423
|
+
const limit = opts?.limit ?? 50;
|
|
1424
|
+
return all.slice(offset, offset + limit);
|
|
1425
|
+
}
|
|
1426
|
+
async getConversation(id) {
|
|
1427
|
+
const tx = this.tx("conversations");
|
|
1428
|
+
const result = await this.req(tx.objectStore("conversations").get(id));
|
|
1429
|
+
return result ?? null;
|
|
1430
|
+
}
|
|
1431
|
+
// ── Contacts ────────────────────────────────────────────────
|
|
1432
|
+
async putContacts(contacts) {
|
|
1433
|
+
const tx = this.tx("contacts", "readwrite");
|
|
1434
|
+
const store = tx.objectStore("contacts");
|
|
1435
|
+
for (const c of contacts) store.put(c);
|
|
1436
|
+
return new Promise((resolve, reject) => {
|
|
1437
|
+
tx.oncomplete = () => resolve();
|
|
1438
|
+
tx.onerror = () => reject(tx.error);
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
async getContacts() {
|
|
1442
|
+
const tx = this.tx("contacts");
|
|
1443
|
+
return this.req(tx.objectStore("contacts").getAll());
|
|
1444
|
+
}
|
|
1445
|
+
// ── Cursors ─────────────────────────────────────────────────
|
|
1446
|
+
async getCursor(key) {
|
|
1447
|
+
const tx = this.tx("cursors");
|
|
1448
|
+
const result = await this.req(tx.objectStore("cursors").get(key));
|
|
1449
|
+
return result?.value ?? null;
|
|
1450
|
+
}
|
|
1451
|
+
async setCursor(key, value) {
|
|
1452
|
+
const tx = this.tx("cursors", "readwrite");
|
|
1453
|
+
tx.objectStore("cursors").put({ key, value });
|
|
1454
|
+
return new Promise((resolve, reject) => {
|
|
1455
|
+
tx.oncomplete = () => resolve();
|
|
1456
|
+
tx.onerror = () => reject(tx.error);
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
// ── Outbox ──────────────────────────────────────────────────
|
|
1460
|
+
async enqueue(op) {
|
|
1461
|
+
const tx = this.tx("outbox", "readwrite");
|
|
1462
|
+
tx.objectStore("outbox").put(op);
|
|
1463
|
+
return new Promise((resolve, reject) => {
|
|
1464
|
+
tx.oncomplete = () => resolve();
|
|
1465
|
+
tx.onerror = () => reject(tx.error);
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
async dequeueReady(limit) {
|
|
1469
|
+
const tx = this.tx("outbox", "readwrite");
|
|
1470
|
+
const store = tx.objectStore("outbox");
|
|
1471
|
+
const idx = store.index("status");
|
|
1472
|
+
const pending = await this.req(idx.getAll("pending"));
|
|
1473
|
+
pending.sort((a, b) => a.createdAt - b.createdAt);
|
|
1474
|
+
const batch = pending.slice(0, limit);
|
|
1475
|
+
for (const op of batch) {
|
|
1476
|
+
op.status = "inflight";
|
|
1477
|
+
store.put(op);
|
|
1478
|
+
}
|
|
1479
|
+
return new Promise((resolve, reject) => {
|
|
1480
|
+
tx.oncomplete = () => resolve(batch);
|
|
1481
|
+
tx.onerror = () => reject(tx.error);
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
async ack(opId) {
|
|
1485
|
+
const tx = this.tx("outbox", "readwrite");
|
|
1486
|
+
tx.objectStore("outbox").delete(opId);
|
|
1487
|
+
return new Promise((resolve, reject) => {
|
|
1488
|
+
tx.oncomplete = () => resolve();
|
|
1489
|
+
tx.onerror = () => reject(tx.error);
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
async nack(opId, error, retries) {
|
|
1493
|
+
const tx = this.tx("outbox", "readwrite");
|
|
1494
|
+
const store = tx.objectStore("outbox");
|
|
1495
|
+
const op = await this.req(store.get(opId));
|
|
1496
|
+
if (!op) return;
|
|
1497
|
+
op.retries = retries;
|
|
1498
|
+
op.lastError = error;
|
|
1499
|
+
op.status = retries >= op.maxRetries ? "failed" : "pending";
|
|
1500
|
+
store.put(op);
|
|
1501
|
+
return new Promise((resolve, reject) => {
|
|
1502
|
+
tx.oncomplete = () => resolve();
|
|
1503
|
+
tx.onerror = () => reject(tx.error);
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
async getPendingCount() {
|
|
1507
|
+
const tx = this.tx("outbox");
|
|
1508
|
+
const idx = tx.objectStore("outbox").index("status");
|
|
1509
|
+
const pending = await this.req(idx.count("pending"));
|
|
1510
|
+
const inflight = await this.req(idx.count("inflight"));
|
|
1511
|
+
return pending + inflight;
|
|
1512
|
+
}
|
|
1513
|
+
// ── Search ─────────────────────────────────────────────────
|
|
1514
|
+
async searchMessages(query, opts) {
|
|
1515
|
+
const lower = query.toLowerCase();
|
|
1516
|
+
const limit = opts?.limit ?? 50;
|
|
1517
|
+
const tx = this.tx("messages");
|
|
1518
|
+
let all;
|
|
1519
|
+
if (opts?.conversationId) {
|
|
1520
|
+
const idx = tx.objectStore("messages").index("conversationId");
|
|
1521
|
+
all = await this.req(idx.getAll(opts.conversationId));
|
|
1522
|
+
} else {
|
|
1523
|
+
all = await this.req(tx.objectStore("messages").getAll());
|
|
1524
|
+
}
|
|
1525
|
+
return all.filter((m) => (m.content ?? "").toLowerCase().includes(lower)).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit);
|
|
1526
|
+
}
|
|
1527
|
+
// ── Quota ─────────────────────────────────────────────────
|
|
1528
|
+
async getStorageSize() {
|
|
1529
|
+
if (typeof navigator !== "undefined" && navigator.storage?.estimate) {
|
|
1530
|
+
const est = await navigator.storage.estimate();
|
|
1531
|
+
const total = est.usage ?? 0;
|
|
1532
|
+
return { messages: Math.floor(total * 0.8), conversations: Math.floor(total * 0.2), total };
|
|
1533
|
+
}
|
|
1534
|
+
const tx = this.tx(["messages", "conversations"]);
|
|
1535
|
+
const msgCount = await this.req(tx.objectStore("messages").count());
|
|
1536
|
+
const convCount = await this.req(tx.objectStore("conversations").count());
|
|
1537
|
+
return { messages: msgCount * 500, conversations: convCount * 200, total: msgCount * 500 + convCount * 200 };
|
|
1538
|
+
}
|
|
1539
|
+
async clearOldMessages(conversationId, keepCount) {
|
|
1540
|
+
const tx = this.tx("messages", "readwrite");
|
|
1541
|
+
const store = tx.objectStore("messages");
|
|
1542
|
+
const idx = store.index("conversationId");
|
|
1543
|
+
const all = await this.req(idx.getAll(conversationId));
|
|
1544
|
+
all.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
1545
|
+
const toDelete = all.slice(keepCount);
|
|
1546
|
+
for (const m of toDelete) store.delete(m.id);
|
|
1547
|
+
return new Promise((resolve, reject) => {
|
|
1548
|
+
tx.oncomplete = () => resolve(toDelete.length);
|
|
1549
|
+
tx.onerror = () => reject(tx.error);
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
// ── Lifecycle ───────────────────────────────────────────────
|
|
1553
|
+
async clear() {
|
|
1554
|
+
const tx = this.tx(IDB_STORES, "readwrite");
|
|
1555
|
+
for (const name of IDB_STORES) tx.objectStore(name).clear();
|
|
1556
|
+
return new Promise((resolve, reject) => {
|
|
1557
|
+
tx.oncomplete = () => resolve();
|
|
1558
|
+
tx.onerror = () => reject(tx.error);
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
};
|
|
1562
|
+
var SQLiteStorage = class {
|
|
1563
|
+
constructor(dbPath = "prismer-offline.db") {
|
|
1564
|
+
this.db = null;
|
|
1565
|
+
this.dbPath = dbPath;
|
|
1566
|
+
}
|
|
1567
|
+
async init() {
|
|
1568
|
+
let Database;
|
|
1569
|
+
try {
|
|
1570
|
+
Database = __require("better-sqlite3");
|
|
1571
|
+
} catch {
|
|
1572
|
+
throw new Error(
|
|
1573
|
+
'SQLiteStorage requires the "better-sqlite3" package. Install it with: npm install better-sqlite3\nFor browser environments, use IndexedDBStorage instead.'
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
this.db = new Database(this.dbPath);
|
|
1577
|
+
this.db.pragma("journal_mode = WAL");
|
|
1578
|
+
this.db.pragma("synchronous = NORMAL");
|
|
1579
|
+
this.db.exec(`
|
|
1580
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
1581
|
+
id TEXT PRIMARY KEY,
|
|
1582
|
+
clientId TEXT,
|
|
1583
|
+
conversationId TEXT NOT NULL,
|
|
1584
|
+
content TEXT,
|
|
1585
|
+
type TEXT DEFAULT 'text',
|
|
1586
|
+
senderId TEXT,
|
|
1587
|
+
parentId TEXT,
|
|
1588
|
+
status TEXT DEFAULT 'confirmed',
|
|
1589
|
+
metadata TEXT,
|
|
1590
|
+
createdAt TEXT,
|
|
1591
|
+
updatedAt TEXT,
|
|
1592
|
+
syncSeq INTEGER
|
|
1593
|
+
);
|
|
1594
|
+
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conversationId, createdAt);
|
|
1595
|
+
CREATE INDEX IF NOT EXISTS idx_msg_created ON messages(createdAt);
|
|
1596
|
+
|
|
1597
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
1598
|
+
id TEXT PRIMARY KEY,
|
|
1599
|
+
type TEXT DEFAULT 'direct',
|
|
1600
|
+
title TEXT,
|
|
1601
|
+
lastMessage TEXT,
|
|
1602
|
+
lastMessageAt TEXT,
|
|
1603
|
+
unreadCount INTEGER DEFAULT 0,
|
|
1604
|
+
lastReadMessageId TEXT,
|
|
1605
|
+
members TEXT,
|
|
1606
|
+
metadata TEXT,
|
|
1607
|
+
syncSeq INTEGER,
|
|
1608
|
+
updatedAt TEXT
|
|
1609
|
+
);
|
|
1610
|
+
|
|
1611
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
1612
|
+
userId TEXT PRIMARY KEY,
|
|
1613
|
+
username TEXT,
|
|
1614
|
+
displayName TEXT,
|
|
1615
|
+
role TEXT,
|
|
1616
|
+
conversationId TEXT,
|
|
1617
|
+
lastMessageAt TEXT,
|
|
1618
|
+
unreadCount INTEGER DEFAULT 0,
|
|
1619
|
+
syncSeq INTEGER
|
|
1620
|
+
);
|
|
1621
|
+
|
|
1622
|
+
CREATE TABLE IF NOT EXISTS cursors (
|
|
1623
|
+
key TEXT PRIMARY KEY,
|
|
1624
|
+
value TEXT
|
|
1625
|
+
);
|
|
1626
|
+
|
|
1627
|
+
CREATE TABLE IF NOT EXISTS outbox (
|
|
1628
|
+
id TEXT PRIMARY KEY,
|
|
1629
|
+
type TEXT,
|
|
1630
|
+
method TEXT,
|
|
1631
|
+
path TEXT,
|
|
1632
|
+
body TEXT,
|
|
1633
|
+
query TEXT,
|
|
1634
|
+
status TEXT DEFAULT 'pending',
|
|
1635
|
+
createdAt INTEGER,
|
|
1636
|
+
retries INTEGER DEFAULT 0,
|
|
1637
|
+
maxRetries INTEGER DEFAULT 5,
|
|
1638
|
+
lastError TEXT,
|
|
1639
|
+
idempotencyKey TEXT,
|
|
1640
|
+
localData TEXT
|
|
1641
|
+
);
|
|
1642
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_status ON outbox(status, createdAt);
|
|
1643
|
+
`);
|
|
1644
|
+
this.db.exec(`
|
|
1645
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
1646
|
+
content, id UNINDEXED, conversationId UNINDEXED
|
|
1647
|
+
);
|
|
1648
|
+
`);
|
|
1649
|
+
}
|
|
1650
|
+
ensureDb() {
|
|
1651
|
+
if (!this.db) throw new Error("SQLiteStorage not initialized. Call init() first.");
|
|
1652
|
+
return this.db;
|
|
1653
|
+
}
|
|
1654
|
+
// ── Messages ────────────────────────────────────────────────
|
|
1655
|
+
async putMessages(messages) {
|
|
1656
|
+
const db = this.ensureDb();
|
|
1657
|
+
const insert = db.prepare(`
|
|
1658
|
+
INSERT OR REPLACE INTO messages (id, clientId, conversationId, content, type, senderId, parentId, status, metadata, createdAt, updatedAt, syncSeq)
|
|
1659
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1660
|
+
`);
|
|
1661
|
+
const insertFts = db.prepare(`
|
|
1662
|
+
INSERT OR REPLACE INTO messages_fts (rowid, content, id, conversationId)
|
|
1663
|
+
VALUES ((SELECT rowid FROM messages WHERE id = ?), ?, ?, ?)
|
|
1664
|
+
`);
|
|
1665
|
+
const txn = db.transaction((msgs) => {
|
|
1666
|
+
for (const m of msgs) {
|
|
1667
|
+
insert.run(
|
|
1668
|
+
m.id,
|
|
1669
|
+
m.clientId ?? null,
|
|
1670
|
+
m.conversationId,
|
|
1671
|
+
m.content,
|
|
1672
|
+
m.type,
|
|
1673
|
+
m.senderId,
|
|
1674
|
+
m.parentId ?? null,
|
|
1675
|
+
m.status,
|
|
1676
|
+
m.metadata ? JSON.stringify(m.metadata) : null,
|
|
1677
|
+
m.createdAt,
|
|
1678
|
+
m.updatedAt ?? null,
|
|
1679
|
+
m.syncSeq ?? null
|
|
1680
|
+
);
|
|
1681
|
+
if (m.content) {
|
|
1682
|
+
insertFts.run(m.id, m.content, m.id, m.conversationId);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
});
|
|
1686
|
+
txn(messages);
|
|
1687
|
+
}
|
|
1688
|
+
async getMessages(conversationId, opts) {
|
|
1689
|
+
const db = this.ensureDb();
|
|
1690
|
+
let rows;
|
|
1691
|
+
if (opts.before) {
|
|
1692
|
+
const beforeRow = db.prepare("SELECT createdAt FROM messages WHERE id = ?").get(opts.before);
|
|
1693
|
+
if (beforeRow) {
|
|
1694
|
+
rows = db.prepare(
|
|
1695
|
+
"SELECT * FROM messages WHERE conversationId = ? AND createdAt < ? ORDER BY createdAt DESC LIMIT ?"
|
|
1696
|
+
).all(conversationId, beforeRow.createdAt, opts.limit);
|
|
1697
|
+
} else {
|
|
1698
|
+
rows = db.prepare(
|
|
1699
|
+
"SELECT * FROM messages WHERE conversationId = ? ORDER BY createdAt DESC LIMIT ?"
|
|
1700
|
+
).all(conversationId, opts.limit);
|
|
1701
|
+
}
|
|
1702
|
+
} else {
|
|
1703
|
+
rows = db.prepare(
|
|
1704
|
+
"SELECT * FROM messages WHERE conversationId = ? ORDER BY createdAt DESC LIMIT ?"
|
|
1705
|
+
).all(conversationId, opts.limit);
|
|
1706
|
+
}
|
|
1707
|
+
return rows.reverse().map(this.rowToMessage);
|
|
1708
|
+
}
|
|
1709
|
+
async getMessage(messageId) {
|
|
1710
|
+
const db = this.ensureDb();
|
|
1711
|
+
const row = db.prepare("SELECT * FROM messages WHERE id = ?").get(messageId);
|
|
1712
|
+
return row ? this.rowToMessage(row) : null;
|
|
1713
|
+
}
|
|
1714
|
+
async deleteMessage(messageId) {
|
|
1715
|
+
const db = this.ensureDb();
|
|
1716
|
+
db.prepare("DELETE FROM messages WHERE id = ?").run(messageId);
|
|
1717
|
+
db.prepare("DELETE FROM messages_fts WHERE id = ?").run(messageId);
|
|
1718
|
+
}
|
|
1719
|
+
rowToMessage(row) {
|
|
1720
|
+
return {
|
|
1721
|
+
id: row.id,
|
|
1722
|
+
clientId: row.clientId ?? void 0,
|
|
1723
|
+
conversationId: row.conversationId,
|
|
1724
|
+
content: row.content ?? "",
|
|
1725
|
+
type: row.type ?? "text",
|
|
1726
|
+
senderId: row.senderId ?? "",
|
|
1727
|
+
parentId: row.parentId ?? null,
|
|
1728
|
+
status: row.status ?? "confirmed",
|
|
1729
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : void 0,
|
|
1730
|
+
createdAt: row.createdAt ?? "",
|
|
1731
|
+
updatedAt: row.updatedAt ?? void 0,
|
|
1732
|
+
syncSeq: row.syncSeq ?? void 0
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
// ── Conversations ───────────────────────────────────────────
|
|
1736
|
+
async putConversations(conversations) {
|
|
1737
|
+
const db = this.ensureDb();
|
|
1738
|
+
const insert = db.prepare(`
|
|
1739
|
+
INSERT OR REPLACE INTO conversations (id, type, title, lastMessage, lastMessageAt, unreadCount, lastReadMessageId, members, metadata, syncSeq, updatedAt)
|
|
1740
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1741
|
+
`);
|
|
1742
|
+
const txn = db.transaction((convs) => {
|
|
1743
|
+
for (const c of convs) {
|
|
1744
|
+
insert.run(
|
|
1745
|
+
c.id,
|
|
1746
|
+
c.type,
|
|
1747
|
+
c.title ?? null,
|
|
1748
|
+
c.lastMessage ? JSON.stringify(c.lastMessage) : null,
|
|
1749
|
+
c.lastMessageAt ?? null,
|
|
1750
|
+
c.unreadCount,
|
|
1751
|
+
c.lastReadMessageId ?? null,
|
|
1752
|
+
c.members ? JSON.stringify(c.members) : null,
|
|
1753
|
+
c.metadata ? JSON.stringify(c.metadata) : null,
|
|
1754
|
+
c.syncSeq ?? null,
|
|
1755
|
+
c.updatedAt
|
|
1756
|
+
);
|
|
1757
|
+
}
|
|
1758
|
+
});
|
|
1759
|
+
txn(conversations);
|
|
1760
|
+
}
|
|
1761
|
+
async getConversations(opts) {
|
|
1762
|
+
const db = this.ensureDb();
|
|
1763
|
+
const limit = opts?.limit ?? 50;
|
|
1764
|
+
const offset = opts?.offset ?? 0;
|
|
1765
|
+
const rows = db.prepare(
|
|
1766
|
+
"SELECT * FROM conversations ORDER BY COALESCE(lastMessageAt, updatedAt) DESC LIMIT ? OFFSET ?"
|
|
1767
|
+
).all(limit, offset);
|
|
1768
|
+
return rows.map(this.rowToConversation);
|
|
1769
|
+
}
|
|
1770
|
+
async getConversation(id) {
|
|
1771
|
+
const db = this.ensureDb();
|
|
1772
|
+
const row = db.prepare("SELECT * FROM conversations WHERE id = ?").get(id);
|
|
1773
|
+
return row ? this.rowToConversation(row) : null;
|
|
1774
|
+
}
|
|
1775
|
+
rowToConversation(row) {
|
|
1776
|
+
return {
|
|
1777
|
+
id: row.id,
|
|
1778
|
+
type: row.type ?? "direct",
|
|
1779
|
+
title: row.title ?? void 0,
|
|
1780
|
+
lastMessage: row.lastMessage ? JSON.parse(row.lastMessage) : void 0,
|
|
1781
|
+
lastMessageAt: row.lastMessageAt ?? void 0,
|
|
1782
|
+
unreadCount: row.unreadCount ?? 0,
|
|
1783
|
+
lastReadMessageId: row.lastReadMessageId ?? void 0,
|
|
1784
|
+
members: row.members ? JSON.parse(row.members) : void 0,
|
|
1785
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : void 0,
|
|
1786
|
+
syncSeq: row.syncSeq ?? void 0,
|
|
1787
|
+
updatedAt: row.updatedAt ?? ""
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
// ── Contacts ────────────────────────────────────────────────
|
|
1791
|
+
async putContacts(contacts) {
|
|
1792
|
+
const db = this.ensureDb();
|
|
1793
|
+
const insert = db.prepare(`
|
|
1794
|
+
INSERT OR REPLACE INTO contacts (userId, username, displayName, role, conversationId, lastMessageAt, unreadCount, syncSeq)
|
|
1795
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1796
|
+
`);
|
|
1797
|
+
const txn = db.transaction((cs) => {
|
|
1798
|
+
for (const c of cs) {
|
|
1799
|
+
insert.run(
|
|
1800
|
+
c.userId,
|
|
1801
|
+
c.username,
|
|
1802
|
+
c.displayName,
|
|
1803
|
+
c.role,
|
|
1804
|
+
c.conversationId,
|
|
1805
|
+
c.lastMessageAt ?? null,
|
|
1806
|
+
c.unreadCount,
|
|
1807
|
+
c.syncSeq ?? null
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
});
|
|
1811
|
+
txn(contacts);
|
|
1812
|
+
}
|
|
1813
|
+
async getContacts() {
|
|
1814
|
+
const db = this.ensureDb();
|
|
1815
|
+
return db.prepare("SELECT * FROM contacts").all().map((row) => ({
|
|
1816
|
+
userId: row.userId,
|
|
1817
|
+
username: row.username ?? "",
|
|
1818
|
+
displayName: row.displayName ?? "",
|
|
1819
|
+
role: row.role ?? "member",
|
|
1820
|
+
conversationId: row.conversationId ?? "",
|
|
1821
|
+
lastMessageAt: row.lastMessageAt ?? void 0,
|
|
1822
|
+
unreadCount: row.unreadCount ?? 0,
|
|
1823
|
+
syncSeq: row.syncSeq ?? void 0
|
|
1824
|
+
}));
|
|
1825
|
+
}
|
|
1826
|
+
// ── Cursors ─────────────────────────────────────────────────
|
|
1827
|
+
async getCursor(key) {
|
|
1828
|
+
const db = this.ensureDb();
|
|
1829
|
+
const row = db.prepare("SELECT value FROM cursors WHERE key = ?").get(key);
|
|
1830
|
+
return row?.value ?? null;
|
|
1831
|
+
}
|
|
1832
|
+
async setCursor(key, value) {
|
|
1833
|
+
const db = this.ensureDb();
|
|
1834
|
+
db.prepare("INSERT OR REPLACE INTO cursors (key, value) VALUES (?, ?)").run(key, value);
|
|
1835
|
+
}
|
|
1836
|
+
// ── Outbox ──────────────────────────────────────────────────
|
|
1837
|
+
async enqueue(op) {
|
|
1838
|
+
const db = this.ensureDb();
|
|
1839
|
+
db.prepare(`
|
|
1840
|
+
INSERT OR REPLACE INTO outbox (id, type, method, path, body, query, status, createdAt, retries, maxRetries, lastError, idempotencyKey, localData)
|
|
1841
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1842
|
+
`).run(
|
|
1843
|
+
op.id,
|
|
1844
|
+
op.type,
|
|
1845
|
+
op.method,
|
|
1846
|
+
op.path,
|
|
1847
|
+
op.body ? JSON.stringify(op.body) : null,
|
|
1848
|
+
op.query ? JSON.stringify(op.query) : null,
|
|
1849
|
+
op.status,
|
|
1850
|
+
op.createdAt,
|
|
1851
|
+
op.retries,
|
|
1852
|
+
op.maxRetries,
|
|
1853
|
+
op.lastError ?? null,
|
|
1854
|
+
op.idempotencyKey,
|
|
1855
|
+
op.localData ? JSON.stringify(op.localData) : null
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
async dequeueReady(limit) {
|
|
1859
|
+
const db = this.ensureDb();
|
|
1860
|
+
const rows = db.prepare(
|
|
1861
|
+
"SELECT * FROM outbox WHERE status = ? ORDER BY createdAt ASC LIMIT ?"
|
|
1862
|
+
).all("pending", limit);
|
|
1863
|
+
const ops = rows.map(this.rowToOutbox);
|
|
1864
|
+
const update = db.prepare("UPDATE outbox SET status = ? WHERE id = ?");
|
|
1865
|
+
const txn = db.transaction((items) => {
|
|
1866
|
+
for (const op of items) update.run("inflight", op.id);
|
|
1867
|
+
});
|
|
1868
|
+
txn(ops);
|
|
1869
|
+
return ops.map((op) => ({ ...op, status: "inflight" }));
|
|
1870
|
+
}
|
|
1871
|
+
async ack(opId) {
|
|
1872
|
+
const db = this.ensureDb();
|
|
1873
|
+
db.prepare("DELETE FROM outbox WHERE id = ?").run(opId);
|
|
1874
|
+
}
|
|
1875
|
+
async nack(opId, error, retries) {
|
|
1876
|
+
const db = this.ensureDb();
|
|
1877
|
+
const row = db.prepare("SELECT maxRetries FROM outbox WHERE id = ?").get(opId);
|
|
1878
|
+
const newStatus = row && retries >= row.maxRetries ? "failed" : "pending";
|
|
1879
|
+
db.prepare("UPDATE outbox SET retries = ?, lastError = ?, status = ? WHERE id = ?").run(retries, error, newStatus, opId);
|
|
1880
|
+
}
|
|
1881
|
+
async getPendingCount() {
|
|
1882
|
+
const db = this.ensureDb();
|
|
1883
|
+
const row = db.prepare(
|
|
1884
|
+
"SELECT COUNT(*) as cnt FROM outbox WHERE status IN ('pending', 'inflight')"
|
|
1885
|
+
).get();
|
|
1886
|
+
return row?.cnt ?? 0;
|
|
1887
|
+
}
|
|
1888
|
+
// ── Search (FTS5) ─────────────────────────────────────────
|
|
1889
|
+
async searchMessages(query, opts) {
|
|
1890
|
+
const db = this.ensureDb();
|
|
1891
|
+
const limit = opts?.limit ?? 50;
|
|
1892
|
+
const safeQuery = query.replace(/['"*(){}[\]^~\\]/g, " ").trim();
|
|
1893
|
+
if (!safeQuery) return [];
|
|
1894
|
+
let rows;
|
|
1895
|
+
if (opts?.conversationId) {
|
|
1896
|
+
rows = db.prepare(`
|
|
1897
|
+
SELECT m.* FROM messages m
|
|
1898
|
+
JOIN messages_fts f ON m.id = f.id
|
|
1899
|
+
WHERE messages_fts MATCH ? AND m.conversationId = ?
|
|
1900
|
+
ORDER BY m.createdAt DESC LIMIT ?
|
|
1901
|
+
`).all(safeQuery, opts.conversationId, limit);
|
|
1902
|
+
} else {
|
|
1903
|
+
rows = db.prepare(`
|
|
1904
|
+
SELECT m.* FROM messages m
|
|
1905
|
+
JOIN messages_fts f ON m.id = f.id
|
|
1906
|
+
WHERE messages_fts MATCH ?
|
|
1907
|
+
ORDER BY m.createdAt DESC LIMIT ?
|
|
1908
|
+
`).all(safeQuery, limit);
|
|
1909
|
+
}
|
|
1910
|
+
return rows.map(this.rowToMessage);
|
|
1911
|
+
}
|
|
1912
|
+
// ── Quota ─────────────────────────────────────────────────
|
|
1913
|
+
async getStorageSize() {
|
|
1914
|
+
const db = this.ensureDb();
|
|
1915
|
+
const pageSize = db.pragma("page_size", { simple: true }) ?? 4096;
|
|
1916
|
+
const pageCount = db.pragma("page_count", { simple: true }) ?? 0;
|
|
1917
|
+
const total = pageSize * pageCount;
|
|
1918
|
+
const msgCount = db.prepare("SELECT COUNT(*) as cnt FROM messages").get()?.cnt ?? 0;
|
|
1919
|
+
const convCount = db.prepare("SELECT COUNT(*) as cnt FROM conversations").get()?.cnt ?? 0;
|
|
1920
|
+
const totalRecords = msgCount + convCount;
|
|
1921
|
+
const msgRatio = totalRecords > 0 ? msgCount / totalRecords : 0.8;
|
|
1922
|
+
return {
|
|
1923
|
+
messages: Math.floor(total * msgRatio),
|
|
1924
|
+
conversations: Math.floor(total * (1 - msgRatio)),
|
|
1925
|
+
total
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
async clearOldMessages(conversationId, keepCount) {
|
|
1929
|
+
const db = this.ensureDb();
|
|
1930
|
+
const keepIds = db.prepare(
|
|
1931
|
+
"SELECT id FROM messages WHERE conversationId = ? ORDER BY createdAt DESC LIMIT ?"
|
|
1932
|
+
).all(conversationId, keepCount).map((r) => r.id);
|
|
1933
|
+
if (keepIds.length === 0) return 0;
|
|
1934
|
+
const placeholders = keepIds.map(() => "?").join(",");
|
|
1935
|
+
const result = db.prepare(
|
|
1936
|
+
`DELETE FROM messages WHERE conversationId = ? AND id NOT IN (${placeholders})`
|
|
1937
|
+
).run(conversationId, ...keepIds);
|
|
1938
|
+
db.prepare(
|
|
1939
|
+
`DELETE FROM messages_fts WHERE conversationId = ? AND id NOT IN (${placeholders})`
|
|
1940
|
+
).run(conversationId, ...keepIds);
|
|
1941
|
+
return result.changes;
|
|
1942
|
+
}
|
|
1943
|
+
// ── Lifecycle ───────────────────────────────────────────────
|
|
1944
|
+
async clear() {
|
|
1945
|
+
const db = this.ensureDb();
|
|
1946
|
+
db.exec("DELETE FROM messages; DELETE FROM messages_fts; DELETE FROM conversations; DELETE FROM contacts; DELETE FROM cursors; DELETE FROM outbox;");
|
|
1947
|
+
}
|
|
1948
|
+
rowToOutbox(row) {
|
|
1949
|
+
return {
|
|
1950
|
+
id: row.id,
|
|
1951
|
+
type: row.type,
|
|
1952
|
+
method: row.method,
|
|
1953
|
+
path: row.path,
|
|
1954
|
+
body: row.body ? JSON.parse(row.body) : void 0,
|
|
1955
|
+
query: row.query ? JSON.parse(row.query) : void 0,
|
|
1956
|
+
status: row.status,
|
|
1957
|
+
createdAt: row.createdAt,
|
|
1958
|
+
retries: row.retries ?? 0,
|
|
1959
|
+
maxRetries: row.maxRetries ?? 5,
|
|
1960
|
+
lastError: row.lastError ?? void 0,
|
|
1961
|
+
idempotencyKey: row.idempotencyKey ?? "",
|
|
1962
|
+
localData: row.localData ? JSON.parse(row.localData) : void 0
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
};
|
|
1966
|
+
|
|
1967
|
+
// src/multitab.ts
|
|
1968
|
+
var TabCoordinator = class {
|
|
1969
|
+
constructor(offline, channelName = "prismer-tab-sync") {
|
|
1970
|
+
this.offline = offline;
|
|
1971
|
+
this.channelName = channelName;
|
|
1972
|
+
this.channel = null;
|
|
1973
|
+
this._isLeader = false;
|
|
1974
|
+
this.disposed = false;
|
|
1975
|
+
this.tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1976
|
+
}
|
|
1977
|
+
get isLeader() {
|
|
1978
|
+
return this._isLeader;
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* Initialize tab coordination.
|
|
1982
|
+
* Claims leadership immediately (last-login-wins).
|
|
1983
|
+
*/
|
|
1984
|
+
init() {
|
|
1985
|
+
if (typeof BroadcastChannel === "undefined") {
|
|
1986
|
+
this._isLeader = true;
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
this.channel = new BroadcastChannel(this.channelName);
|
|
1990
|
+
this.channel.onmessage = (e) => this.handleMessage(e.data);
|
|
1991
|
+
this.claimLeadership();
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Release leadership and clean up.
|
|
1995
|
+
*/
|
|
1996
|
+
destroy() {
|
|
1997
|
+
this.disposed = true;
|
|
1998
|
+
if (this.channel) {
|
|
1999
|
+
if (this._isLeader) {
|
|
2000
|
+
this.broadcast({ type: "tab.release", tabId: this.tabId });
|
|
2001
|
+
}
|
|
2002
|
+
this.channel.close();
|
|
2003
|
+
this.channel = null;
|
|
2004
|
+
}
|
|
2005
|
+
this._isLeader = false;
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Relay a sync event to passive tabs.
|
|
2009
|
+
* Called by the leader tab after processing a sync event.
|
|
2010
|
+
*/
|
|
2011
|
+
relaySyncEvent(event) {
|
|
2012
|
+
if (this._isLeader && this.channel) {
|
|
2013
|
+
this.broadcast({ type: "sync.event", tabId: this.tabId, payload: event });
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
// ── Private ──────────────────────────────────────────────────
|
|
2017
|
+
claimLeadership() {
|
|
2018
|
+
this._isLeader = true;
|
|
2019
|
+
this.broadcast({ type: "tab.claim", tabId: this.tabId });
|
|
2020
|
+
this.onBecomeLeader();
|
|
2021
|
+
}
|
|
2022
|
+
demoteToPassive() {
|
|
2023
|
+
if (!this._isLeader) return;
|
|
2024
|
+
this._isLeader = false;
|
|
2025
|
+
this.onBecomePassive();
|
|
2026
|
+
}
|
|
2027
|
+
handleMessage(msg) {
|
|
2028
|
+
if (this.disposed) return;
|
|
2029
|
+
switch (msg.type) {
|
|
2030
|
+
case "tab.claim": {
|
|
2031
|
+
if (msg.tabId !== this.tabId) {
|
|
2032
|
+
this.demoteToPassive();
|
|
2033
|
+
this.broadcast({ type: "tab.ack", tabId: this.tabId });
|
|
2034
|
+
}
|
|
2035
|
+
break;
|
|
2036
|
+
}
|
|
2037
|
+
case "tab.release": {
|
|
2038
|
+
if (!this._isLeader) {
|
|
2039
|
+
this.claimLeadership();
|
|
2040
|
+
}
|
|
2041
|
+
break;
|
|
2042
|
+
}
|
|
2043
|
+
case "sync.event": {
|
|
2044
|
+
if (!this._isLeader && msg.payload) {
|
|
2045
|
+
this.offline["applySyncEvent"](msg.payload).catch(() => {
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
break;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
onBecomeLeader() {
|
|
2053
|
+
}
|
|
2054
|
+
onBecomePassive() {
|
|
2055
|
+
this.offline.stopContinuousSync();
|
|
2056
|
+
}
|
|
2057
|
+
broadcast(msg) {
|
|
2058
|
+
try {
|
|
2059
|
+
this.channel?.postMessage(msg);
|
|
2060
|
+
} catch {
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
};
|
|
2064
|
+
|
|
2065
|
+
// src/encryption.ts
|
|
2066
|
+
function getSubtleCrypto() {
|
|
2067
|
+
if (typeof globalThis.crypto?.subtle !== "undefined") {
|
|
2068
|
+
return globalThis.crypto.subtle;
|
|
2069
|
+
}
|
|
2070
|
+
try {
|
|
2071
|
+
const { webcrypto } = __require("crypto");
|
|
2072
|
+
return webcrypto.subtle;
|
|
2073
|
+
} catch {
|
|
2074
|
+
throw new Error("No SubtleCrypto available. Requires browser or Node.js 16+.");
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
function getRandomValues(arr) {
|
|
2078
|
+
if (typeof globalThis.crypto?.getRandomValues !== "undefined") {
|
|
2079
|
+
return globalThis.crypto.getRandomValues(arr);
|
|
2080
|
+
}
|
|
2081
|
+
try {
|
|
2082
|
+
const { webcrypto } = __require("crypto");
|
|
2083
|
+
return webcrypto.getRandomValues(arr);
|
|
2084
|
+
} catch {
|
|
2085
|
+
throw new Error("No crypto.getRandomValues available.");
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
var subtle = () => getSubtleCrypto();
|
|
2089
|
+
var PBKDF2_ITERATIONS = 1e5;
|
|
2090
|
+
var SALT_LENGTH = 16;
|
|
2091
|
+
var IV_LENGTH = 12;
|
|
2092
|
+
var KEY_LENGTH = 256;
|
|
2093
|
+
var E2EEncryption = class {
|
|
2094
|
+
constructor() {
|
|
2095
|
+
this.masterKey = null;
|
|
2096
|
+
this.keyPair = null;
|
|
2097
|
+
this.sessionKeys = /* @__PURE__ */ new Map();
|
|
2098
|
+
// conversationId → AES key
|
|
2099
|
+
this.salt = null;
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* Initialize encryption with user passphrase.
|
|
2103
|
+
* Derives a master key via PBKDF2 and generates an ECDH key pair.
|
|
2104
|
+
*/
|
|
2105
|
+
async init(passphrase) {
|
|
2106
|
+
this.salt = getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
2107
|
+
const passphraseKey = await subtle().importKey(
|
|
2108
|
+
"raw",
|
|
2109
|
+
new TextEncoder().encode(passphrase),
|
|
2110
|
+
"PBKDF2",
|
|
2111
|
+
false,
|
|
2112
|
+
["deriveKey"]
|
|
2113
|
+
);
|
|
2114
|
+
this.masterKey = await subtle().deriveKey(
|
|
2115
|
+
{
|
|
2116
|
+
name: "PBKDF2",
|
|
2117
|
+
salt: this.salt,
|
|
2118
|
+
iterations: PBKDF2_ITERATIONS,
|
|
2119
|
+
hash: "SHA-256"
|
|
2120
|
+
},
|
|
2121
|
+
passphraseKey,
|
|
2122
|
+
{ name: "AES-GCM", length: KEY_LENGTH },
|
|
2123
|
+
false,
|
|
2124
|
+
["encrypt", "decrypt"]
|
|
2125
|
+
);
|
|
2126
|
+
this.keyPair = await subtle().generateKey(
|
|
2127
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
2128
|
+
true,
|
|
2129
|
+
["deriveKey"]
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
/**
|
|
2133
|
+
* Export public key for sharing with conversation peers.
|
|
2134
|
+
*/
|
|
2135
|
+
async exportPublicKey() {
|
|
2136
|
+
if (!this.keyPair) throw new Error("E2E not initialized. Call init() first.");
|
|
2137
|
+
return subtle().exportKey("jwk", this.keyPair.publicKey);
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Derive a shared session key for a conversation using ECDH.
|
|
2141
|
+
* Call this with each peer's public key.
|
|
2142
|
+
*/
|
|
2143
|
+
async deriveSessionKey(conversationId, peerPublicKey) {
|
|
2144
|
+
if (!this.keyPair) throw new Error("E2E not initialized. Call init() first.");
|
|
2145
|
+
const importedPeerKey = await subtle().importKey(
|
|
2146
|
+
"jwk",
|
|
2147
|
+
peerPublicKey,
|
|
2148
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
2149
|
+
false,
|
|
2150
|
+
[]
|
|
2151
|
+
);
|
|
2152
|
+
const sessionKey = await subtle().deriveKey(
|
|
2153
|
+
{ name: "ECDH", public: importedPeerKey },
|
|
2154
|
+
this.keyPair.privateKey,
|
|
2155
|
+
{ name: "AES-GCM", length: KEY_LENGTH },
|
|
2156
|
+
false,
|
|
2157
|
+
["encrypt", "decrypt"]
|
|
2158
|
+
);
|
|
2159
|
+
this.sessionKeys.set(conversationId, sessionKey);
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* Set a pre-shared session key for a conversation.
|
|
2163
|
+
* Useful when the key is exchanged out-of-band or derived from a group key.
|
|
2164
|
+
*/
|
|
2165
|
+
async setSessionKey(conversationId, rawKey) {
|
|
2166
|
+
const key = await subtle().importKey(
|
|
2167
|
+
"raw",
|
|
2168
|
+
rawKey,
|
|
2169
|
+
{ name: "AES-GCM", length: KEY_LENGTH },
|
|
2170
|
+
false,
|
|
2171
|
+
["encrypt", "decrypt"]
|
|
2172
|
+
);
|
|
2173
|
+
this.sessionKeys.set(conversationId, key);
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Generate a random session key for a conversation.
|
|
2177
|
+
* Returns the raw key bytes for sharing with peers.
|
|
2178
|
+
*/
|
|
2179
|
+
async generateSessionKey(conversationId) {
|
|
2180
|
+
const key = await subtle().generateKey(
|
|
2181
|
+
{ name: "AES-GCM", length: KEY_LENGTH },
|
|
2182
|
+
true,
|
|
2183
|
+
["encrypt", "decrypt"]
|
|
2184
|
+
);
|
|
2185
|
+
this.sessionKeys.set(conversationId, key);
|
|
2186
|
+
return subtle().exportKey("raw", key);
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Encrypt plaintext for a conversation.
|
|
2190
|
+
* Returns base64-encoded ciphertext with prepended IV.
|
|
2191
|
+
*/
|
|
2192
|
+
async encrypt(conversationId, plaintext) {
|
|
2193
|
+
const key = this.sessionKeys.get(conversationId);
|
|
2194
|
+
if (!key) throw new Error(`No session key for conversation ${conversationId}. Call deriveSessionKey() first.`);
|
|
2195
|
+
const iv = getRandomValues(new Uint8Array(IV_LENGTH));
|
|
2196
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
2197
|
+
const ciphertext = await subtle().encrypt(
|
|
2198
|
+
{ name: "AES-GCM", iv },
|
|
2199
|
+
key,
|
|
2200
|
+
encoded
|
|
2201
|
+
);
|
|
2202
|
+
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
|
2203
|
+
combined.set(iv, 0);
|
|
2204
|
+
combined.set(new Uint8Array(ciphertext), iv.length);
|
|
2205
|
+
return arrayBufferToBase64(combined.buffer);
|
|
2206
|
+
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Decrypt ciphertext from a conversation.
|
|
2209
|
+
* Expects base64-encoded data with prepended IV.
|
|
2210
|
+
*/
|
|
2211
|
+
async decrypt(conversationId, ciphertext) {
|
|
2212
|
+
const key = this.sessionKeys.get(conversationId);
|
|
2213
|
+
if (!key) throw new Error(`No session key for conversation ${conversationId}. Call deriveSessionKey() first.`);
|
|
2214
|
+
const combined = base64ToArrayBuffer(ciphertext);
|
|
2215
|
+
const iv = combined.slice(0, IV_LENGTH);
|
|
2216
|
+
const data = combined.slice(IV_LENGTH);
|
|
2217
|
+
const decrypted = await subtle().decrypt(
|
|
2218
|
+
{ name: "AES-GCM", iv: new Uint8Array(iv) },
|
|
2219
|
+
key,
|
|
2220
|
+
data
|
|
2221
|
+
);
|
|
2222
|
+
return new TextDecoder().decode(decrypted);
|
|
2223
|
+
}
|
|
2224
|
+
/**
|
|
2225
|
+
* Check if a session key exists for a conversation.
|
|
2226
|
+
*/
|
|
2227
|
+
hasSessionKey(conversationId) {
|
|
2228
|
+
return this.sessionKeys.has(conversationId);
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Remove session key for a conversation.
|
|
2232
|
+
*/
|
|
2233
|
+
removeSessionKey(conversationId) {
|
|
2234
|
+
this.sessionKeys.delete(conversationId);
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* Clear all keys and reset state.
|
|
2238
|
+
*/
|
|
2239
|
+
destroy() {
|
|
2240
|
+
this.masterKey = null;
|
|
2241
|
+
this.keyPair = null;
|
|
2242
|
+
this.sessionKeys.clear();
|
|
2243
|
+
this.salt = null;
|
|
2244
|
+
}
|
|
2245
|
+
};
|
|
2246
|
+
function arrayBufferToBase64(buffer) {
|
|
2247
|
+
if (typeof btoa !== "undefined") {
|
|
2248
|
+
const bytes = new Uint8Array(buffer);
|
|
2249
|
+
let binary = "";
|
|
2250
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
2251
|
+
binary += String.fromCharCode(bytes[i]);
|
|
2252
|
+
}
|
|
2253
|
+
return btoa(binary);
|
|
2254
|
+
}
|
|
2255
|
+
return Buffer.from(buffer).toString("base64");
|
|
2256
|
+
}
|
|
2257
|
+
function base64ToArrayBuffer(base64) {
|
|
2258
|
+
if (typeof atob !== "undefined") {
|
|
2259
|
+
const binary = atob(base64);
|
|
2260
|
+
const bytes = new Uint8Array(binary.length);
|
|
2261
|
+
for (let i = 0; i < binary.length; i++) {
|
|
2262
|
+
bytes[i] = binary.charCodeAt(i);
|
|
2263
|
+
}
|
|
2264
|
+
return bytes.buffer;
|
|
2265
|
+
}
|
|
2266
|
+
const buf = Buffer.from(base64, "base64");
|
|
2267
|
+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
433
2270
|
// src/index.ts
|
|
434
2271
|
var AccountClient = class {
|
|
435
2272
|
constructor(_r) {
|
|
@@ -464,8 +2301,8 @@ var DirectClient = class {
|
|
|
464
2301
|
/** Get direct message history with a user */
|
|
465
2302
|
async getMessages(userId, options) {
|
|
466
2303
|
const query = {};
|
|
467
|
-
if (options?.limit) query.limit = String(options.limit);
|
|
468
|
-
if (options?.offset) query.offset = String(options.offset);
|
|
2304
|
+
if (options?.limit != null) query.limit = String(options.limit);
|
|
2305
|
+
if (options?.offset != null) query.offset = String(options.offset);
|
|
469
2306
|
return this._r("GET", `/api/im/direct/${userId}/messages`, void 0, query);
|
|
470
2307
|
}
|
|
471
2308
|
};
|
|
@@ -497,8 +2334,8 @@ var GroupsClient = class {
|
|
|
497
2334
|
/** Get group message history */
|
|
498
2335
|
async getMessages(groupId, options) {
|
|
499
2336
|
const query = {};
|
|
500
|
-
if (options?.limit) query.limit = String(options.limit);
|
|
501
|
-
if (options?.offset) query.offset = String(options.offset);
|
|
2337
|
+
if (options?.limit != null) query.limit = String(options.limit);
|
|
2338
|
+
if (options?.offset != null) query.offset = String(options.offset);
|
|
502
2339
|
return this._r("GET", `/api/im/groups/${groupId}/messages`, void 0, query);
|
|
503
2340
|
}
|
|
504
2341
|
/** Add a member to a group (owner/admin only) */
|
|
@@ -550,8 +2387,8 @@ var MessagesClient = class {
|
|
|
550
2387
|
/** Get message history for a conversation */
|
|
551
2388
|
async getHistory(conversationId, options) {
|
|
552
2389
|
const query = {};
|
|
553
|
-
if (options?.limit) query.limit = String(options.limit);
|
|
554
|
-
if (options?.offset) query.offset = String(options.offset);
|
|
2390
|
+
if (options?.limit != null) query.limit = String(options.limit);
|
|
2391
|
+
if (options?.offset != null) query.offset = String(options.offset);
|
|
555
2392
|
return this._r("GET", `/api/im/messages/${conversationId}`, void 0, query);
|
|
556
2393
|
}
|
|
557
2394
|
/** Edit a message */
|
|
@@ -611,8 +2448,8 @@ var CreditsClient = class {
|
|
|
611
2448
|
/** Get credit transaction history */
|
|
612
2449
|
async transactions(options) {
|
|
613
2450
|
const query = {};
|
|
614
|
-
if (options?.limit) query.limit = String(options.limit);
|
|
615
|
-
if (options?.offset) query.offset = String(options.offset);
|
|
2451
|
+
if (options?.limit != null) query.limit = String(options.limit);
|
|
2452
|
+
if (options?.offset != null) query.offset = String(options.offset);
|
|
616
2453
|
return this._r("GET", "/api/im/credits/transactions", void 0, query);
|
|
617
2454
|
}
|
|
618
2455
|
};
|
|
@@ -643,6 +2480,211 @@ var WorkspaceClient = class {
|
|
|
643
2480
|
return this._r("GET", "/api/im/workspace/mentions/autocomplete", void 0, q);
|
|
644
2481
|
}
|
|
645
2482
|
};
|
|
2483
|
+
function guessMimeType(fileName) {
|
|
2484
|
+
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
|
2485
|
+
const map = {
|
|
2486
|
+
png: "image/png",
|
|
2487
|
+
jpg: "image/jpeg",
|
|
2488
|
+
jpeg: "image/jpeg",
|
|
2489
|
+
gif: "image/gif",
|
|
2490
|
+
webp: "image/webp",
|
|
2491
|
+
svg: "image/svg+xml",
|
|
2492
|
+
ico: "image/x-icon",
|
|
2493
|
+
bmp: "image/bmp",
|
|
2494
|
+
pdf: "application/pdf",
|
|
2495
|
+
doc: "application/msword",
|
|
2496
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
2497
|
+
xls: "application/vnd.ms-excel",
|
|
2498
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
2499
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
2500
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
2501
|
+
txt: "text/plain",
|
|
2502
|
+
csv: "text/csv",
|
|
2503
|
+
html: "text/html",
|
|
2504
|
+
css: "text/css",
|
|
2505
|
+
js: "text/javascript",
|
|
2506
|
+
json: "application/json",
|
|
2507
|
+
xml: "application/xml",
|
|
2508
|
+
md: "text/markdown",
|
|
2509
|
+
yaml: "text/yaml",
|
|
2510
|
+
yml: "text/yaml",
|
|
2511
|
+
zip: "application/zip",
|
|
2512
|
+
gz: "application/gzip",
|
|
2513
|
+
tar: "application/x-tar",
|
|
2514
|
+
mp3: "audio/mpeg",
|
|
2515
|
+
wav: "audio/wav",
|
|
2516
|
+
mp4: "video/mp4",
|
|
2517
|
+
webm: "video/webm"
|
|
2518
|
+
};
|
|
2519
|
+
return map[ext] || "application/octet-stream";
|
|
2520
|
+
}
|
|
2521
|
+
var FilesClient = class {
|
|
2522
|
+
constructor(_r, _baseUrl, _fetchFn, _getAuthHeaders) {
|
|
2523
|
+
this._r = _r;
|
|
2524
|
+
this._baseUrl = _baseUrl;
|
|
2525
|
+
this._fetchFn = _fetchFn;
|
|
2526
|
+
this._getAuthHeaders = _getAuthHeaders;
|
|
2527
|
+
}
|
|
2528
|
+
/** Get a presigned upload URL */
|
|
2529
|
+
async presign(options) {
|
|
2530
|
+
return this._r("POST", "/api/im/files/presign", options);
|
|
2531
|
+
}
|
|
2532
|
+
/** Confirm an uploaded file (triggers validation + CDN activation) */
|
|
2533
|
+
async confirm(uploadId) {
|
|
2534
|
+
return this._r("POST", "/api/im/files/confirm", { uploadId });
|
|
2535
|
+
}
|
|
2536
|
+
/** Get storage quota */
|
|
2537
|
+
async quota() {
|
|
2538
|
+
return this._r("GET", "/api/im/files/quota");
|
|
2539
|
+
}
|
|
2540
|
+
/** Delete a file */
|
|
2541
|
+
async delete(uploadId) {
|
|
2542
|
+
return this._r("DELETE", `/api/im/files/${uploadId}`);
|
|
2543
|
+
}
|
|
2544
|
+
/** List allowed MIME types */
|
|
2545
|
+
async types() {
|
|
2546
|
+
return this._r("GET", "/api/im/files/types");
|
|
2547
|
+
}
|
|
2548
|
+
/** Initialize a multipart upload (for files > 10 MB) */
|
|
2549
|
+
async initMultipart(opts) {
|
|
2550
|
+
return this._r("POST", "/api/im/files/upload/init", opts);
|
|
2551
|
+
}
|
|
2552
|
+
/** Complete a multipart upload */
|
|
2553
|
+
async completeMultipart(uploadId, parts) {
|
|
2554
|
+
return this._r("POST", "/api/im/files/upload/complete", { uploadId, parts });
|
|
2555
|
+
}
|
|
2556
|
+
// --------------------------------------------------------------------------
|
|
2557
|
+
// High-level convenience methods
|
|
2558
|
+
// --------------------------------------------------------------------------
|
|
2559
|
+
/**
|
|
2560
|
+
* Upload a file (full lifecycle: presign → upload → confirm).
|
|
2561
|
+
*
|
|
2562
|
+
* @param input - File, Blob, Buffer, Uint8Array, or file path (Node.js string)
|
|
2563
|
+
* @param opts - Optional fileName, mimeType, onProgress
|
|
2564
|
+
* @returns Confirmed upload result with CDN URL
|
|
2565
|
+
*/
|
|
2566
|
+
async upload(input, opts) {
|
|
2567
|
+
let bytes;
|
|
2568
|
+
let fileName;
|
|
2569
|
+
if (typeof input === "string") {
|
|
2570
|
+
const fs = await import("fs");
|
|
2571
|
+
const path = await import("path");
|
|
2572
|
+
const buf = await fs.promises.readFile(input);
|
|
2573
|
+
bytes = new Uint8Array(buf);
|
|
2574
|
+
fileName = opts?.fileName || path.basename(input);
|
|
2575
|
+
} else if (typeof Blob !== "undefined" && input instanceof Blob) {
|
|
2576
|
+
const ab = await input.arrayBuffer();
|
|
2577
|
+
bytes = new Uint8Array(ab);
|
|
2578
|
+
fileName = opts?.fileName || (input instanceof File ? input.name : "");
|
|
2579
|
+
if (!fileName) throw new Error("fileName is required when uploading Blob without name");
|
|
2580
|
+
} else if (input instanceof Uint8Array) {
|
|
2581
|
+
bytes = input;
|
|
2582
|
+
fileName = opts?.fileName || "";
|
|
2583
|
+
if (!fileName) throw new Error("fileName is required when uploading Buffer or Uint8Array");
|
|
2584
|
+
} else {
|
|
2585
|
+
throw new Error("Unsupported input type");
|
|
2586
|
+
}
|
|
2587
|
+
const fileSize = bytes.byteLength;
|
|
2588
|
+
const mimeType = opts?.mimeType || guessMimeType(fileName);
|
|
2589
|
+
if (fileSize > 50 * 1024 * 1024) {
|
|
2590
|
+
throw new Error("File exceeds maximum size of 50 MB");
|
|
2591
|
+
}
|
|
2592
|
+
if (fileSize <= 10 * 1024 * 1024) {
|
|
2593
|
+
return this._uploadSimple(bytes, fileName, fileSize, mimeType, opts?.onProgress);
|
|
2594
|
+
}
|
|
2595
|
+
return this._uploadMultipart(bytes, fileName, fileSize, mimeType, opts?.onProgress);
|
|
2596
|
+
}
|
|
2597
|
+
/**
|
|
2598
|
+
* Upload a file and send it as a message in one call.
|
|
2599
|
+
*
|
|
2600
|
+
* @param conversationId - Target conversation
|
|
2601
|
+
* @param input - File input (same as upload())
|
|
2602
|
+
* @param opts - Upload options + optional message content/parentId
|
|
2603
|
+
*/
|
|
2604
|
+
async sendFile(conversationId, input, opts) {
|
|
2605
|
+
const uploaded = await this.upload(input, opts);
|
|
2606
|
+
const msgRes = await this._r("POST", `/api/im/messages/${conversationId}`, {
|
|
2607
|
+
content: opts?.content || uploaded.fileName,
|
|
2608
|
+
type: "file",
|
|
2609
|
+
metadata: {
|
|
2610
|
+
uploadId: uploaded.uploadId,
|
|
2611
|
+
fileUrl: uploaded.cdnUrl,
|
|
2612
|
+
fileName: uploaded.fileName,
|
|
2613
|
+
fileSize: uploaded.fileSize,
|
|
2614
|
+
mimeType: uploaded.mimeType
|
|
2615
|
+
},
|
|
2616
|
+
parentId: opts?.parentId
|
|
2617
|
+
});
|
|
2618
|
+
if (!msgRes.ok) {
|
|
2619
|
+
throw new Error(msgRes.error?.message || "Failed to send file message");
|
|
2620
|
+
}
|
|
2621
|
+
return { upload: uploaded, message: msgRes.data };
|
|
2622
|
+
}
|
|
2623
|
+
// --------------------------------------------------------------------------
|
|
2624
|
+
// Private upload helpers
|
|
2625
|
+
// --------------------------------------------------------------------------
|
|
2626
|
+
async _uploadSimple(bytes, fileName, fileSize, mimeType, onProgress) {
|
|
2627
|
+
const presignRes = await this.presign({ fileName, fileSize, mimeType });
|
|
2628
|
+
if (!presignRes.ok || !presignRes.data) {
|
|
2629
|
+
throw new Error(presignRes.error?.message || "Presign failed");
|
|
2630
|
+
}
|
|
2631
|
+
const { uploadId, url, fields } = presignRes.data;
|
|
2632
|
+
const formData = new FormData();
|
|
2633
|
+
const isS3 = url.startsWith("http");
|
|
2634
|
+
const uploadUrl = isS3 ? url : `${this._baseUrl}${url}`;
|
|
2635
|
+
if (isS3) {
|
|
2636
|
+
for (const [k, v] of Object.entries(fields)) formData.append(k, v);
|
|
2637
|
+
}
|
|
2638
|
+
const ab = new ArrayBuffer(bytes.byteLength);
|
|
2639
|
+
new Uint8Array(ab).set(bytes);
|
|
2640
|
+
formData.append("file", new Blob([ab], { type: mimeType }), fileName);
|
|
2641
|
+
const headers = {};
|
|
2642
|
+
if (!isS3) Object.assign(headers, this._getAuthHeaders());
|
|
2643
|
+
const resp = await this._fetchFn(uploadUrl, { method: "POST", body: formData, headers });
|
|
2644
|
+
if (!resp.ok) {
|
|
2645
|
+
const text = await resp.text();
|
|
2646
|
+
throw new Error(`Upload failed (${resp.status}): ${text}`);
|
|
2647
|
+
}
|
|
2648
|
+
onProgress?.(fileSize, fileSize);
|
|
2649
|
+
const confirmRes = await this.confirm(uploadId);
|
|
2650
|
+
if (!confirmRes.ok || !confirmRes.data) {
|
|
2651
|
+
throw new Error(confirmRes.error?.message || "Confirm failed");
|
|
2652
|
+
}
|
|
2653
|
+
return confirmRes.data;
|
|
2654
|
+
}
|
|
2655
|
+
async _uploadMultipart(bytes, fileName, fileSize, mimeType, onProgress) {
|
|
2656
|
+
const initRes = await this.initMultipart({ fileName, fileSize, mimeType });
|
|
2657
|
+
if (!initRes.ok || !initRes.data) {
|
|
2658
|
+
throw new Error(initRes.error?.message || "Multipart init failed");
|
|
2659
|
+
}
|
|
2660
|
+
const { uploadId, parts: partUrls } = initRes.data;
|
|
2661
|
+
const CHUNK_SIZE = 5 * 1024 * 1024;
|
|
2662
|
+
const completedParts = [];
|
|
2663
|
+
let uploaded = 0;
|
|
2664
|
+
for (const part of partUrls) {
|
|
2665
|
+
const start = (part.partNumber - 1) * CHUNK_SIZE;
|
|
2666
|
+
const end = Math.min(start + CHUNK_SIZE, fileSize);
|
|
2667
|
+
const chunk = bytes.slice(start, end);
|
|
2668
|
+
const isS3 = part.url.startsWith("http");
|
|
2669
|
+
const partUrl = isS3 ? part.url : `${this._baseUrl}${part.url}`;
|
|
2670
|
+
const headers = { "Content-Type": mimeType };
|
|
2671
|
+
if (!isS3) Object.assign(headers, this._getAuthHeaders());
|
|
2672
|
+
const resp = await this._fetchFn(partUrl, { method: "PUT", body: chunk, headers });
|
|
2673
|
+
if (!resp.ok) {
|
|
2674
|
+
throw new Error(`Part ${part.partNumber} upload failed (${resp.status})`);
|
|
2675
|
+
}
|
|
2676
|
+
const etag = resp.headers.get("ETag") || `"part-${part.partNumber}"`;
|
|
2677
|
+
completedParts.push({ partNumber: part.partNumber, etag });
|
|
2678
|
+
uploaded += chunk.byteLength;
|
|
2679
|
+
onProgress?.(uploaded, fileSize);
|
|
2680
|
+
}
|
|
2681
|
+
const completeRes = await this.completeMultipart(uploadId, completedParts);
|
|
2682
|
+
if (!completeRes.ok || !completeRes.data) {
|
|
2683
|
+
throw new Error(completeRes.error?.message || "Multipart complete failed");
|
|
2684
|
+
}
|
|
2685
|
+
return completeRes.data;
|
|
2686
|
+
}
|
|
2687
|
+
};
|
|
646
2688
|
var IMRealtimeClient = class {
|
|
647
2689
|
constructor(_wsBase) {
|
|
648
2690
|
this._wsBase = _wsBase;
|
|
@@ -666,7 +2708,7 @@ var IMRealtimeClient = class {
|
|
|
666
2708
|
}
|
|
667
2709
|
};
|
|
668
2710
|
var IMClient = class {
|
|
669
|
-
constructor(request, wsBase) {
|
|
2711
|
+
constructor(request, wsBase, fetchFn, getAuthHeaders, offlineManager) {
|
|
670
2712
|
this.account = new AccountClient(request);
|
|
671
2713
|
this.direct = new DirectClient(request);
|
|
672
2714
|
this.groups = new GroupsClient(request);
|
|
@@ -676,7 +2718,9 @@ var IMClient = class {
|
|
|
676
2718
|
this.bindings = new BindingsClient(request);
|
|
677
2719
|
this.credits = new CreditsClient(request);
|
|
678
2720
|
this.workspace = new WorkspaceClient(request);
|
|
2721
|
+
this.files = new FilesClient(request, wsBase, fetchFn, getAuthHeaders);
|
|
679
2722
|
this.realtime = new IMRealtimeClient(wsBase);
|
|
2723
|
+
this.offline = offlineManager ?? null;
|
|
680
2724
|
}
|
|
681
2725
|
/** IM health check */
|
|
682
2726
|
async health() {
|
|
@@ -685,6 +2729,7 @@ var IMClient = class {
|
|
|
685
2729
|
};
|
|
686
2730
|
var PrismerClient = class {
|
|
687
2731
|
constructor(config = {}) {
|
|
2732
|
+
this._offlineManager = null;
|
|
688
2733
|
if (config.apiKey && !config.apiKey.startsWith("sk-prismer-") && !config.apiKey.startsWith("eyJ")) {
|
|
689
2734
|
console.warn('Warning: API key should start with "sk-prismer-" (or "eyJ" for IM JWT)');
|
|
690
2735
|
}
|
|
@@ -694,11 +2739,32 @@ var PrismerClient = class {
|
|
|
694
2739
|
this.timeout = config.timeout || 3e4;
|
|
695
2740
|
this.fetchFn = config.fetch || fetch;
|
|
696
2741
|
this.imAgent = config.imAgent;
|
|
2742
|
+
if (config.offline) {
|
|
2743
|
+
this._offlineManager = new OfflineManager(
|
|
2744
|
+
config.offline.storage,
|
|
2745
|
+
(m, p, b, q) => this._request(m, p, b, q),
|
|
2746
|
+
config.offline
|
|
2747
|
+
);
|
|
2748
|
+
this._offlineManager.init().catch(
|
|
2749
|
+
(err) => console.warn("[PrismerSDK] Offline storage init failed:", err)
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
2752
|
+
const imRequest = this._offlineManager ? (m, p, b, q) => this._offlineManager.dispatch(m, p, b, q) : (m, p, b, q) => this._request(m, p, b, q);
|
|
697
2753
|
this.im = new IMClient(
|
|
698
|
-
|
|
699
|
-
this.baseUrl
|
|
2754
|
+
imRequest,
|
|
2755
|
+
this.baseUrl,
|
|
2756
|
+
this.fetchFn,
|
|
2757
|
+
() => this._getAuthHeaders(),
|
|
2758
|
+
this._offlineManager
|
|
700
2759
|
);
|
|
701
2760
|
}
|
|
2761
|
+
/** Build auth headers for raw HTTP requests (used by file upload) */
|
|
2762
|
+
_getAuthHeaders() {
|
|
2763
|
+
const headers = {};
|
|
2764
|
+
if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
2765
|
+
if (this.imAgent) headers["X-IM-Agent"] = this.imAgent;
|
|
2766
|
+
return headers;
|
|
2767
|
+
}
|
|
702
2768
|
/**
|
|
703
2769
|
* Set or update the auth token (API key or IM JWT).
|
|
704
2770
|
* Useful after anonymous registration to set the returned JWT.
|
|
@@ -706,10 +2772,16 @@ var PrismerClient = class {
|
|
|
706
2772
|
setToken(token) {
|
|
707
2773
|
this.apiKey = token;
|
|
708
2774
|
}
|
|
2775
|
+
/** Cleanup resources (offline manager, timers). Call when disposing the client. */
|
|
2776
|
+
async destroy() {
|
|
2777
|
+
if (this._offlineManager) {
|
|
2778
|
+
await this._offlineManager.destroy();
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
709
2781
|
// --------------------------------------------------------------------------
|
|
710
2782
|
// Internal request helper
|
|
711
2783
|
// --------------------------------------------------------------------------
|
|
712
|
-
async _request(method, path, body, query) {
|
|
2784
|
+
async _request(method, path, body, query, _isRetry) {
|
|
713
2785
|
const controller = new AbortController();
|
|
714
2786
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
715
2787
|
try {
|
|
@@ -731,6 +2803,16 @@ var PrismerClient = class {
|
|
|
731
2803
|
}
|
|
732
2804
|
const response = await this.fetchFn(url, init);
|
|
733
2805
|
const data = await response.json();
|
|
2806
|
+
if (response.status === 401 && this.apiKey.startsWith("eyJ") && !_isRetry && !path.includes("/token/refresh")) {
|
|
2807
|
+
try {
|
|
2808
|
+
const refreshRes = await this._request("POST", "/api/im/token/refresh", void 0, void 0, true);
|
|
2809
|
+
if (refreshRes?.ok && refreshRes?.data?.token) {
|
|
2810
|
+
this.apiKey = refreshRes.data.token;
|
|
2811
|
+
return this._request(method, path, body, query, true);
|
|
2812
|
+
}
|
|
2813
|
+
} catch {
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
734
2816
|
if (!response.ok) {
|
|
735
2817
|
const err = data.error || { code: "HTTP_ERROR", message: `Request failed with status ${response.status}` };
|
|
736
2818
|
return { ...data, success: false, ok: false, error: err };
|
|
@@ -810,19 +2892,27 @@ function createClient(config) {
|
|
|
810
2892
|
}
|
|
811
2893
|
export {
|
|
812
2894
|
AccountClient,
|
|
2895
|
+
AttachmentQueue,
|
|
813
2896
|
BindingsClient,
|
|
814
2897
|
ContactsClient,
|
|
815
2898
|
ConversationsClient,
|
|
816
2899
|
CreditsClient,
|
|
817
2900
|
DirectClient,
|
|
2901
|
+
E2EEncryption,
|
|
818
2902
|
ENVIRONMENTS,
|
|
2903
|
+
FilesClient,
|
|
819
2904
|
GroupsClient,
|
|
820
2905
|
IMClient,
|
|
821
2906
|
IMRealtimeClient,
|
|
2907
|
+
IndexedDBStorage,
|
|
2908
|
+
MemoryStorage,
|
|
822
2909
|
MessagesClient,
|
|
2910
|
+
OfflineManager,
|
|
823
2911
|
PrismerClient,
|
|
824
2912
|
RealtimeSSEClient,
|
|
825
2913
|
RealtimeWSClient,
|
|
2914
|
+
SQLiteStorage,
|
|
2915
|
+
TabCoordinator,
|
|
826
2916
|
WorkspaceClient,
|
|
827
2917
|
createClient,
|
|
828
2918
|
index_default as default
|