@antzsoft/chat-core 1.2.3 → 1.2.5

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 CHANGED
@@ -45,7 +45,7 @@ npm install @antzsoft/chat-core
45
45
  | Authentication | Login, register, logout, token refresh (automatic on 401) |
46
46
  | Conversations | List, create (group/DM), update, delete, mute, pin, leave, manage members |
47
47
  | Messages | Send, edit, delete, react, star, pin, search, paginate |
48
- | File uploads | Presigned URL pipeline — request URL → upload binary → confirm |
48
+ | File uploads | Presigned URL pipeline — request URL → upload binary (multipart POST for S3/local, PUT for Azure) → confirm. Files ≥ 10 MB on S3 or local use chunked multipart (parallel parts → complete). |
49
49
  | Real-time | Socket.IO wrapper — send/receive messages, typing, read receipts, presence |
50
50
  | State management | Zustand auth store (persisted) + chat store (typing users, online status, reply/edit state) |
51
51
  | Push notifications | Device token registration/removal API |
@@ -115,11 +115,18 @@ const platformUploadFn = async (presigned, file, onProgress) => {
115
115
  // React Native — fetch (works on Expo and bare RN)
116
116
  const platformUploadFn = async (presigned, file, onProgress) => {
117
117
  onProgress?.(0);
118
- const res = await fetch(presigned.uploadUrl, {
119
- method: presigned.method,
120
- headers: presigned.headers,
121
- body: { uri: file.uri, name: file.name, type: file.type } as any,
122
- });
118
+ let body: any;
119
+ if (presigned.method === 'POST' && presigned.fields) {
120
+ // S3 / local — multipart FormData with signed policy fields
121
+ const fd = new FormData();
122
+ Object.entries(presigned.fields).forEach(([k, v]) => fd.append(k, v as string));
123
+ fd.append('file', { uri: file.uri, name: file.name, type: file.type } as any);
124
+ body = fd;
125
+ } else {
126
+ // Azure — raw blob PUT
127
+ body = { uri: file.uri, name: file.name, type: file.type } as any;
128
+ }
129
+ const res = await fetch(presigned.uploadUrl, { method: presigned.method, headers: presigned.headers, body });
123
130
  if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
124
131
  onProgress?.(1);
125
132
  };
@@ -127,12 +134,21 @@ const platformUploadFn = async (presigned, file, onProgress) => {
127
134
  // Node.js — fs + fetch (Node 18+)
128
135
  import { readFileSync } from 'fs';
129
136
  const platformUploadFn = async (presigned, file) => {
130
- const body = readFileSync(file.uri.replace('file://', ''));
131
- const res = await fetch(presigned.uploadUrl, {
132
- method: presigned.method,
133
- headers: presigned.headers,
134
- body,
135
- });
137
+ let body: any;
138
+ let headers: Record<string, string> = { ...presigned.headers };
139
+ if (presigned.method === 'POST' && presigned.fields) {
140
+ // S3 / local — multipart FormData with signed policy fields
141
+ const { FormData, Blob } = await import('node:buffer') as any;
142
+ const fd = new FormData();
143
+ Object.entries(presigned.fields).forEach(([k, v]) => fd.append(k, v as string));
144
+ fd.append('file', new Blob([readFileSync(file.uri.replace('file://', ''))], { type: file.type }), file.name);
145
+ body = fd;
146
+ } else {
147
+ // Azure — raw buffer PUT
148
+ body = readFileSync(file.uri.replace('file://', ''));
149
+ headers['Content-Type'] = file.type;
150
+ }
151
+ const res = await fetch(presigned.uploadUrl, { method: presigned.method, headers, body });
136
152
  if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
137
153
  };
138
154
  ```
@@ -217,7 +233,15 @@ const platformUploadFn = async (presigned, file, onProgress) => {
217
233
  xhr.upload.onprogress = (e) => onProgress?.(e.loaded / e.total);
218
234
  xhr.onload = () => (xhr.status < 400 ? resolve() : reject(new Error(`Upload failed: ${xhr.status}`)));
219
235
  xhr.onerror = () => reject(new Error('Network error during upload'));
220
- xhr.send(file.uri ? null : (file as any)); // blob or body per presigned.method
236
+ // S3 and local storage return method:'POST' with signed fields multipart FormData.
237
+ // Azure returns method:'PUT' → raw blob body.
238
+ if (presigned.method === 'POST' && presigned.fields) {
239
+ const fd = new FormData();
240
+ Object.entries(presigned.fields).forEach(([k, v]) => fd.append(k, v));
241
+ fetch(file.uri).then(r => r.blob()).then(blob => { fd.append('file', blob, file.name); xhr.send(fd); }).catch(reject);
242
+ } else {
243
+ fetch(file.uri).then(r => r.blob()).then(blob => xhr.send(blob)).catch(reject);
244
+ }
221
245
  });
222
246
  };
223
247
 
@@ -434,28 +458,22 @@ const platformUploadFn: PlatformUploadFn = (presigned, file, onProgress) =>
434
458
  **React Native implementation (fetch):**
435
459
 
436
460
  ```typescript
437
- import * as FileSystem from 'expo-file-system';
438
-
439
461
  const platformUploadFn: PlatformUploadFn = async (presigned, file, onProgress) => {
440
- const callback = (progress: FileSystem.UploadProgressData) => {
441
- onProgress?.(progress.totalBytesSent / progress.totalBytesExpectedToSend);
442
- };
443
-
444
- const uploadTask = FileSystem.createUploadTask(
445
- presigned.uploadUrl,
446
- file.uri,
447
- {
448
- httpMethod: presigned.method,
449
- headers: presigned.headers,
450
- uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
451
- },
452
- callback,
453
- );
454
-
455
- const result = await uploadTask.uploadAsync();
456
- if (!result || result.status >= 400) {
457
- throw new Error(`Upload failed: ${result?.status}`);
462
+ onProgress?.(0);
463
+ let body: any;
464
+ if (presigned.method === 'POST' && presigned.fields) {
465
+ // S3 and local storage — multipart FormData with signed policy fields
466
+ const fd = new FormData();
467
+ Object.entries(presigned.fields).forEach(([k, v]) => fd.append(k, v as string));
468
+ fd.append('file', { uri: file.uri, name: file.name, type: file.type } as any);
469
+ body = fd;
470
+ } else {
471
+ // Azure Blob Storage — raw body PUT (no FormData API available on Azure)
472
+ body = { uri: file.uri, name: file.name, type: file.type } as any;
458
473
  }
474
+ const res = await fetch(presigned.uploadUrl, { method: presigned.method, headers: presigned.headers, body });
475
+ if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
476
+ onProgress?.(1);
459
477
  };
460
478
  ```
461
479
 
@@ -970,7 +988,9 @@ console.log(updated.iconUrl); // fresh signed URL, regenerated on every response
970
988
  // What client.uploadIcon() does internally (same as attachment upload):
971
989
  // 1. uploadFiles([file], conversationId)
972
990
  // → POST /storage/presigned-url (creates temp chat_files record)
973
- // → platformUploadFn uploads binary directly to S3/Azure/local
991
+ // → platformUploadFn uploads binary directly to S3/Azure/local:
992
+ // S3 / local: multipart POST with signed policy fields (FormData)
993
+ // Azure: raw buffer PUT with SAS token in URL
974
994
  // → POST /storage/confirm/:fileId (marks chat_files active)
975
995
  // 2. conversationsApi.uploadIcon(conversationId, fileId)
976
996
  // → PUT /conversations/:id/icon { fileId }
@@ -1098,7 +1118,8 @@ import { storageApi, uploadBatch } from '@antzsoft/chat-core';
1098
1118
  |---|---|---|
1099
1119
  | `requestPresignedUrl` | `(payload: PresignedUrlRequest) => Promise<PresignedUrlResponse>` | Request a single presigned upload URL. |
1100
1120
  | `requestPresignedUrlBatch` | `(files: PresignedUrlRequest[]) => Promise<{ urls: PresignedUrlResponse[]; errors: Array<{ filename: string; error: string }> }>` | Batch presigned URL request. |
1101
- | `confirmUpload` | `(fileId: string) => Promise<FileResponse>` | Confirm that a binary upload is complete. Required after every upload. |
1121
+ | `confirmUpload` | `(fileId: string) => Promise<FileResponse>` | Confirm a single-part upload is complete. Required after single-part uploads. Not called for chunked multipart — `completeMultipartUpload` handles that. |
1122
+ | `completeMultipartUpload` | `(fileId: string, uploadId: string, parts: CompletedPart[]) => Promise<FileResponse>` | Complete a chunked multipart upload. Assembles parts on S3 and transitions the file record to active in one call. Called automatically by `uploadBatch` — only needed for manual flows. |
1102
1123
  | `getFile` | `(fileId: string) => Promise<FileResponse>` | Fetch file metadata. |
1103
1124
  | `getFileUrl` | `(fileId: string, expiresIn?: number) => Promise<{ url: string; expiresAt: string }>` | Get a fresh signed URL for an already-uploaded file. |
1104
1125
  | `deleteFile` | `(fileId: string) => Promise<void>` | Delete a file. |
@@ -1113,10 +1134,15 @@ function uploadBatch(
1113
1134
  platformUploadFn: PlatformUploadFn,
1114
1135
  conversationId?: string,
1115
1136
  onProgress?: (pct: number) => void,
1137
+ platformCompressFn?: PlatformCompressFn,
1138
+ compressionConfig?: ResolvedCompressionConfig,
1139
+ platformUploadPartFn?: PlatformUploadPartFn,
1116
1140
  ): Promise<BatchUploadResult>
1117
1141
  ```
1118
1142
 
1119
- Handles the full upload pipeline: batch presign → parallel binary upload → confirm each file. Returns `{ successful: FileResponse[]; failed: Array<{ filename: string; error: string }> }`.
1143
+ Handles the full upload pipeline: batch presign → parallel binary upload → confirm (or complete for multipart) each file. Returns `{ successful: FileResponse[]; failed: Array<{ filename: string; error: string }> }`.
1144
+
1145
+ For files ≥ 10 MB on S3, `uploadBatch` automatically switches to chunked multipart upload when `platformUploadPartFn` is provided (the Web and RN SDKs wire this in automatically — no changes needed for integrators using those SDKs). Files below the threshold always use the existing single-part presigned POST flow.
1120
1146
 
1121
1147
  ```typescript
1122
1148
  // Manual: get a presigned URL, upload, confirm
@@ -2527,6 +2553,14 @@ document.querySelectorAll('[data-conv-id]').forEach((el) => {
2527
2553
 
2528
2554
  ## Changelog
2529
2555
 
2556
+ ### v1.2.4
2557
+ - **New: Chunked multipart upload for files ≥ 10 MB (S3 and local)** — Files at or above 10 MB on S3 now use the S3 multipart upload protocol (`CreateMultipartUpload` → parallel `UploadPart` per 10 MB chunk → `CompleteMultipartUpload`) instead of a single presigned POST. Benefits: resumable on network failure, parallel part uploads (max 3 concurrent), no single-request timeout risk, supports files up to 5 TB. Files below 10 MB continue to use the existing single-part presigned POST flow unchanged. Local provider also uses chunked multipart for files ≥ 10 MB (parts land in a staging directory, assembled on complete). Azure is unaffected (stays presigned PUT). **No integration changes required for Web and RN SDK users** — `webUploadPartFn` and `rnUploadPartFn` are wired in automatically. Node.js / custom integrators must supply `platformUploadPartFn` to `uploadBatch` to enable chunked uploads; without it the multipart path is skipped and files fail if they exceed the S3 single-PUT limit.
2558
+ - **New: `storageApi.completeMultipartUpload(fileId, uploadId, parts)`** — Completes an in-progress S3 multipart upload, assembling all parts and transitioning the file record to active. Called automatically by `uploadBatch` — only needed for custom manual upload flows.
2559
+ - **New: `PlatformUploadPartFn` type** — `(uploadUrl: string, blob: Blob | ArrayBuffer, onProgress?) => Promise<string>`. Platform-specific function for uploading a single part and returning its ETag. Provided by Web and RN SDKs automatically; custom environments must supply their own.
2560
+ - **New types exported:** `MultipartUploadInfo`, `MultipartPartUrl`, `CompletedPart`.
2561
+ - **Improvement: S3 and local storage uploads now use presigned POST (multipart) instead of presigned PUT** — File bytes are uploaded directly from the client to S3 (or the local server in same-server/mount mode) as `multipart/form-data` using AWS presigned POST policy. The server generates a short-lived URL and signed fields; the client appends the file as the final `'file'` field and POSTs the form directly to the storage endpoint — no file bytes ever touch the chat server. Azure Blob Storage is unchanged (stays presigned PUT — Azure has no form-based upload API). **No integration changes required** — `platformUploadFn` already branches on `presigned.method === 'POST' && presigned.fields` in both the Web and RN SDKs. Existing integrators using the recommended `platformUploadFn` implementations are unaffected.
2562
+ - **Fix: `POST /storage/files/upload` now works with transit encryption enabled** — The local-storage direct upload endpoint was missing the `@PreTransit()` decorator, causing `403 Transit encryption required` rejections when `transitEncryption: true`. XHR/fetch calls that upload directly to the server (not via the Axios client) never carry an `x-transit-session` header — `@PreTransit()` marks the endpoint as reachable before or without a transit session. Auth is the HMAC signature in the form fields. **No integration changes required.**
2563
+
2530
2564
  ### v1.2.3
2531
2565
  - **New: `messagesApi.getReceipts(messageId)` — message info screen API** — New `GET /messages/:id/receipts` endpoint returns per-user read and delivery receipts for a single message with resolved user profiles (name, avatar) included. Use as the initial data load for a "Read by / Delivered to" detail screen — no secondary user lookup needed. Returns `{ messageId, readBy: [{ userId, displayName, avatarUrl, readAt }], deliveredTo: [{ userId, displayName, avatarUrl, deliveredAt }] }`. New exported types: `MessageReceiptsResponse`, `MessageReceiptEntry`.
2532
2566
  - **Fix: `message_delivered` now fires per recipient, not all-or-nothing** — Previously the server only emitted `message_delivered` to the sender when every recipient was online simultaneously at send time. It now fires once per online recipient at send time. The `deliveredTo` field is now a single `{ userId, deliveredAt }` object (previously an array of all recipients). This matches how `read_receipt` works and enables the delivery section of a message info screen to update live one entry at a time. **Backward compatible** — existing handlers only read `event.messageId` to update the tick mark and are unaffected.
@@ -0,0 +1,7 @@
1
+ import {
2
+ useChatStore
3
+ } from "./chunk-EOL5B7GS.js";
4
+ export {
5
+ useChatStore
6
+ };
7
+ //# sourceMappingURL=chat.store-DLNRJ5ZT.js.map
@@ -12,6 +12,7 @@ var useChatStore = create((set) => ({
12
12
  isSidebarOpen: true,
13
13
  isGroupInfoOpen: false,
14
14
  isStarredPanelOpen: false,
15
+ messageInfoId: null,
15
16
  setActiveConversation: (id) => set({ activeConversationId: id, replyingTo: null, editingMessage: null }),
16
17
  setPendingTarget: (target) => set({ pendingTarget: target }),
17
18
  addTypingUser: (conversationId, user) => set((state) => {
@@ -49,10 +50,11 @@ var useChatStore = create((set) => ({
49
50
  toggleGroupInfo: () => set((state) => ({ isGroupInfoOpen: !state.isGroupInfoOpen })),
50
51
  setGroupInfoOpen: (open) => set({ isGroupInfoOpen: open }),
51
52
  toggleStarredPanel: () => set((state) => ({ isStarredPanelOpen: !state.isStarredPanelOpen })),
52
- setStarredPanelOpen: (open) => set({ isStarredPanelOpen: open })
53
+ setStarredPanelOpen: (open) => set({ isStarredPanelOpen: open }),
54
+ setMessageInfoId: (id) => set({ messageInfoId: id })
53
55
  }));
54
56
 
55
57
  export {
56
58
  useChatStore
57
59
  };
58
- //# sourceMappingURL=chunk-GUO5QQGK.js.map
60
+ //# sourceMappingURL=chunk-EOL5B7GS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/stores/chat.store.ts"],"sourcesContent":["import { create } from 'zustand';\nimport type { Message } from '../types/index.js';\n\ninterface TypingUser {\n userId: string;\n displayName: string;\n avatarUrl?: string;\n}\n\nexport interface LastReadEntry {\n messageId: string;\n readAt: string;\n}\n\ninterface ChatState {\n activeConversationId: string | null;\n pendingTarget: { conversationId: string; messageId: string } | null;\n typingUsers: Record<string, TypingUser[]>;\n onlineUsers: string[];\n /** keyed by conversationId — current user's last read pointer per conversation */\n lastRead: Record<string, LastReadEntry>;\n /** keyed by userId — last seen timestamp for each user */\n lastSeen: Record<string, string>;\n replyingTo: Message | null;\n editingMessage: Message | null;\n isSidebarOpen: boolean;\n isGroupInfoOpen: boolean;\n isStarredPanelOpen: boolean;\n /** messageId currently shown in the Message Info panel, null = closed */\n messageInfoId: string | null;\n\n setActiveConversation: (id: string | null) => void;\n setPendingTarget: (target: { conversationId: string; messageId: string } | null) => void;\n addTypingUser: (conversationId: string, user: TypingUser) => void;\n removeTypingUser: (conversationId: string, userId: string) => void;\n setUserOnline: (userId: string) => void;\n setUserOffline: (userId: string) => void;\n setOnlineUsers: (userIds: string[]) => void;\n setLastRead: (conversationId: string, messageId: string, readAt: string) => void;\n setLastSeen: (userId: string, lastSeenAt: string | null) => void;\n setReplyingTo: (message: Message | null) => void;\n setEditingMessage: (message: Message | null) => void;\n toggleSidebar: () => void;\n setSidebarOpen: (open: boolean) => void;\n toggleGroupInfo: () => void;\n setGroupInfoOpen: (open: boolean) => void;\n toggleStarredPanel: () => void;\n setStarredPanelOpen: (open: boolean) => void;\n setMessageInfoId: (id: string | null) => void;\n}\n\nexport const useChatStore = create<ChatState>((set) => ({\n activeConversationId: null,\n pendingTarget: null,\n typingUsers: {},\n onlineUsers: [],\n lastRead: {},\n lastSeen: {},\n replyingTo: null,\n editingMessage: null,\n isSidebarOpen: true,\n isGroupInfoOpen: false,\n isStarredPanelOpen: false,\n messageInfoId: null,\n\n setActiveConversation: (id) =>\n set({ activeConversationId: id, replyingTo: null, editingMessage: null }),\n\n setPendingTarget: (target) => set({ pendingTarget: target }),\n\n addTypingUser: (conversationId, user) =>\n set((state) => {\n const existing = state.typingUsers[conversationId] ?? [];\n const deduped = existing.filter((u) => u.userId !== user.userId);\n return { typingUsers: { ...state.typingUsers, [conversationId]: [...deduped, user] } };\n }),\n\n removeTypingUser: (conversationId, userId) =>\n set((state) => ({\n typingUsers: {\n ...state.typingUsers,\n [conversationId]: (state.typingUsers[conversationId] ?? []).filter(\n (u) => u.userId !== userId,\n ),\n },\n })),\n\n setUserOnline: (userId) =>\n set((state) => ({\n onlineUsers: state.onlineUsers.includes(userId)\n ? state.onlineUsers\n : [...state.onlineUsers, userId],\n })),\n\n setUserOffline: (userId) =>\n set((state) => ({ onlineUsers: state.onlineUsers.filter((id) => id !== userId) })),\n\n setOnlineUsers: (userIds) => set({ onlineUsers: userIds }),\n\n setLastRead: (conversationId, messageId, readAt) =>\n set((state) => ({\n lastRead: { ...state.lastRead, [conversationId]: { messageId, readAt } },\n })),\n\n setLastSeen: (userId, lastSeenAt) =>\n set((state) => {\n if (lastSeenAt === null) {\n const { [userId]: _, ...rest } = state.lastSeen;\n return { lastSeen: rest };\n }\n return { lastSeen: { ...state.lastSeen, [userId]: lastSeenAt } };\n }),\n\n setReplyingTo: (message) => set({ replyingTo: message, editingMessage: null }),\n\n setEditingMessage: (message) => set({ editingMessage: message, replyingTo: null }),\n\n toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),\n setSidebarOpen: (open) => set({ isSidebarOpen: open }),\n\n toggleGroupInfo: () => set((state) => ({ isGroupInfoOpen: !state.isGroupInfoOpen })),\n setGroupInfoOpen: (open) => set({ isGroupInfoOpen: open }),\n\n toggleStarredPanel: () => set((state) => ({ isStarredPanelOpen: !state.isStarredPanelOpen })),\n setStarredPanelOpen: (open) => set({ isStarredPanelOpen: open }),\n\n setMessageInfoId: (id: string | null) => set({ messageInfoId: id }),\n}));\n"],"mappings":"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","names":[]}
@@ -60,67 +60,6 @@ async function compressFile(file, platformCompressFn, config) {
60
60
  }
61
61
  }
62
62
 
63
- // src/crypto/session.ts
64
- var _KEY = /* @__PURE__ */ Symbol.for("__antz_chat_transit__");
65
- function getState() {
66
- const g = globalThis;
67
- if (!g[_KEY]) {
68
- g[_KEY] = {
69
- session: null,
70
- sessionEverEstablished: false,
71
- readyResolve: null,
72
- readyPromise: null,
73
- transitConfigured: null
74
- };
75
- }
76
- return g[_KEY];
77
- }
78
- function configureTransit(enabled) {
79
- const s = getState();
80
- s.transitConfigured = enabled;
81
- if (!enabled) {
82
- s.readyResolve?.();
83
- s.readyResolve = null;
84
- }
85
- }
86
- function waitForTransitReady() {
87
- const s = getState();
88
- if (!s.transitConfigured) return Promise.resolve();
89
- if (s.session) return Promise.resolve();
90
- if (s.sessionEverEstablished) return Promise.resolve();
91
- if (!s.readyPromise) {
92
- s.readyPromise = new Promise((resolve) => {
93
- s.readyResolve = resolve;
94
- });
95
- }
96
- return s.readyPromise;
97
- }
98
- function setTransitSession(session) {
99
- const s = getState();
100
- s.session = session;
101
- s.sessionEverEstablished = true;
102
- s.readyResolve?.();
103
- s.readyResolve = null;
104
- }
105
- function clearTransitSession() {
106
- const s = getState();
107
- s.session = null;
108
- s.readyPromise = null;
109
- s.readyResolve = null;
110
- }
111
- function isTransitEnabled() {
112
- return getState().session?.enabled === true;
113
- }
114
- function getSessionKey() {
115
- return getState().session?.sessionKey ?? null;
116
- }
117
- function getSessionId() {
118
- return getState().session?.sessionId ?? null;
119
- }
120
-
121
- // src/api/client.ts
122
- import axios from "axios";
123
-
124
63
  // src/crypto/transit.ts
125
64
  function hasWebCrypto() {
126
65
  return typeof globalThis.crypto?.subtle !== "undefined";
@@ -212,10 +151,218 @@ function base64ToUint8(b64) {
212
151
  return buf;
213
152
  }
214
153
 
154
+ // src/crypto/session.ts
155
+ var _KEY = /* @__PURE__ */ Symbol.for("__antz_chat_transit__");
156
+ function getState() {
157
+ const g = globalThis;
158
+ if (!g[_KEY]) {
159
+ g[_KEY] = {
160
+ session: null,
161
+ sessionEverEstablished: false,
162
+ readyResolve: null,
163
+ readyPromise: null,
164
+ transitConfigured: null
165
+ };
166
+ }
167
+ return g[_KEY];
168
+ }
169
+ function configureTransit(enabled) {
170
+ const s = getState();
171
+ s.transitConfigured = enabled;
172
+ if (!enabled) {
173
+ s.readyResolve?.();
174
+ s.readyResolve = null;
175
+ }
176
+ }
177
+ function waitForTransitReady() {
178
+ const s = getState();
179
+ if (!s.transitConfigured) return Promise.resolve();
180
+ if (s.session) return Promise.resolve();
181
+ if (s.sessionEverEstablished) return Promise.resolve();
182
+ if (!s.readyPromise) {
183
+ s.readyPromise = new Promise((resolve) => {
184
+ s.readyResolve = resolve;
185
+ });
186
+ }
187
+ return s.readyPromise;
188
+ }
189
+ function setTransitSession(session) {
190
+ const s = getState();
191
+ s.session = session;
192
+ s.sessionEverEstablished = true;
193
+ s.readyResolve?.();
194
+ s.readyResolve = null;
195
+ }
196
+ function getTransitSession() {
197
+ return getState().session;
198
+ }
199
+ function clearTransitSession() {
200
+ const s = getState();
201
+ s.session = null;
202
+ s.readyPromise = null;
203
+ s.readyResolve = null;
204
+ }
205
+ function isTransitEnabled() {
206
+ return getState().session?.enabled === true;
207
+ }
208
+ function getSessionKey() {
209
+ return getState().session?.sessionKey ?? null;
210
+ }
211
+ function getSessionId() {
212
+ return getState().session?.sessionId ?? null;
213
+ }
214
+
215
+ // src/crypto/detect.ts
216
+ var _cached = null;
217
+ async function detectTransitAlgo() {
218
+ if (_cached) return _cached;
219
+ try {
220
+ await globalThis.crypto.subtle.generateKey(
221
+ { name: "X25519" },
222
+ false,
223
+ ["deriveKey"]
224
+ );
225
+ _cached = "x25519";
226
+ } catch {
227
+ _cached = "p256";
228
+ }
229
+ return _cached;
230
+ }
231
+ function resetAlgoCache() {
232
+ _cached = null;
233
+ }
234
+
235
+ // src/crypto/handshake.ts
236
+ function hasWebCrypto2() {
237
+ return typeof globalThis.crypto?.subtle !== "undefined";
238
+ }
239
+ async function fetchServerKeys(apiUrl) {
240
+ const res = await fetch(`${apiUrl}/crypto/pubkey`);
241
+ if (!res.ok) throw new Error(`[AntzChat] Failed to fetch server public key: ${res.status}`);
242
+ const body = await res.json();
243
+ return body?.data ?? body;
244
+ }
245
+ async function generateEphemeralKey(algo, serverKeys) {
246
+ if (hasWebCrypto2()) {
247
+ return generateWebCryptoEphemeralKey(algo, serverKeys);
248
+ }
249
+ return generateNobleEphemeralKey(serverKeys);
250
+ }
251
+ async function createRestTransitSession(apiUrl) {
252
+ try {
253
+ const serverKeys = await fetchServerKeys(apiUrl);
254
+ if (!serverKeys.enabled) return null;
255
+ const algo = hasWebCrypto2() ? await detectTransitAlgo() : "x25519";
256
+ const { ephemeralPubB64, deriveSessionKey } = await generateEphemeralKey(algo, serverKeys);
257
+ const res = await fetch(`${apiUrl}/crypto/session`, {
258
+ method: "POST",
259
+ headers: { "Content-Type": "application/json" },
260
+ body: JSON.stringify({ ephemeralPub: ephemeralPubB64, algo })
261
+ });
262
+ if (!res.ok) return null;
263
+ const body = await res.json();
264
+ const sessionId = (body?.data ?? body)?.sessionId ?? body?.sessionId;
265
+ if (!sessionId) return null;
266
+ const sessionKey = await deriveSessionKey(sessionId);
267
+ return { sessionId, sessionKey };
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+ async function performHandshake(algo, serverKeys, socketHandshakeAuth) {
273
+ const { ephemeralPubB64, deriveSessionKey } = await generateEphemeralKey(algo, serverKeys);
274
+ socketHandshakeAuth["transitEphemeralPub"] = ephemeralPubB64;
275
+ socketHandshakeAuth["transitAlgo"] = algo;
276
+ return deriveSessionKey;
277
+ }
278
+ async function generateWebCryptoEphemeralKey(algo, serverKeys) {
279
+ const ephemeral = await globalThis.crypto.subtle.generateKey(
280
+ algo === "x25519" ? { name: "X25519" } : { name: "ECDH", namedCurve: "P-256" },
281
+ false,
282
+ ["deriveBits"]
283
+ );
284
+ const pubRaw = await globalThis.crypto.subtle.exportKey("raw", ephemeral.publicKey);
285
+ const ephemeralPriv = ephemeral.privateKey;
286
+ return {
287
+ ephemeralPubB64: bufToB642(pubRaw),
288
+ deriveSessionKey: (sessionId) => deriveWebCryptoSessionKey(ephemeralPriv, algo, serverKeys, sessionId)
289
+ };
290
+ }
291
+ async function deriveWebCryptoSessionKey(ephemeralPriv, algo, serverKeys, sessionId) {
292
+ const serverPubRaw = b64ToBuf2(algo === "x25519" ? serverKeys.x25519 : serverKeys.p256);
293
+ const keyAlgoParams = algo === "x25519" ? { name: "X25519" } : { name: "ECDH", namedCurve: "P-256" };
294
+ const serverPubKey = await globalThis.crypto.subtle.importKey("raw", serverPubRaw, keyAlgoParams, false, []);
295
+ const sharedBits = await globalThis.crypto.subtle.deriveBits(
296
+ { name: algo === "x25519" ? "X25519" : "ECDH", public: serverPubKey },
297
+ ephemeralPriv,
298
+ 256
299
+ );
300
+ const hkdfKey = await globalThis.crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
301
+ const salt = new TextEncoder().encode(sessionId);
302
+ const info = new TextEncoder().encode("antz-transit-v1");
303
+ return globalThis.crypto.subtle.deriveKey(
304
+ { name: "HKDF", hash: "SHA-256", salt, info },
305
+ hkdfKey,
306
+ { name: "AES-GCM", length: 256 },
307
+ false,
308
+ ["encrypt", "decrypt"]
309
+ );
310
+ }
311
+ async function generateNobleEphemeralKey(serverKeys) {
312
+ const { x25519 } = await import("@noble/curves/ed25519");
313
+ const { hkdf } = await import("@noble/hashes/hkdf");
314
+ const { sha256 } = await import("@noble/hashes/sha256");
315
+ const { randomBytes } = await import("@noble/hashes/utils");
316
+ const ephemeralPriv = randomBytes(32);
317
+ const ephemeralPub = x25519.getPublicKey(ephemeralPriv);
318
+ const serverPubBytes = base64ToUint82(serverKeys.x25519);
319
+ return {
320
+ ephemeralPubB64: uint8ToBase64(ephemeralPub),
321
+ deriveSessionKey: (sessionId) => {
322
+ const sharedSecret = x25519.getSharedSecret(ephemeralPriv, serverPubBytes);
323
+ const salt = new TextEncoder().encode(sessionId);
324
+ const info = new TextEncoder().encode("antz-transit-v1");
325
+ return Promise.resolve(hkdf(sha256, sharedSecret, salt, info, 32));
326
+ }
327
+ };
328
+ }
329
+ function bufToB642(buf) {
330
+ const bytes = new Uint8Array(buf);
331
+ let str = "";
332
+ bytes.forEach((b) => {
333
+ str += String.fromCharCode(b);
334
+ });
335
+ return btoa(str);
336
+ }
337
+ function b64ToBuf2(b64) {
338
+ const bin = atob(b64);
339
+ const buf = new Uint8Array(bin.length);
340
+ for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
341
+ return buf.buffer;
342
+ }
343
+ function uint8ToBase64(bytes) {
344
+ let str = "";
345
+ bytes.forEach((b) => {
346
+ str += String.fromCharCode(b);
347
+ });
348
+ return btoa(str);
349
+ }
350
+ function base64ToUint82(b64) {
351
+ const bin = atob(b64);
352
+ const buf = new Uint8Array(bin.length);
353
+ for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
354
+ return buf;
355
+ }
356
+
215
357
  // src/api/client.ts
358
+ import axios from "axios";
216
359
  var _tokenStore = null;
217
360
  var _config = null;
218
361
  var _avatarSent = false;
362
+ var _transitHandshakePromise = null;
363
+ function getTransitHandshakePromise() {
364
+ return _transitHandshakePromise;
365
+ }
219
366
  function initApiClient(config, tokenStore) {
220
367
  _config = config;
221
368
  _tokenStore = tokenStore;
@@ -225,6 +372,23 @@ function initApiClient(config, tokenStore) {
225
372
  headers: { "Content-Type": "application/json" }
226
373
  });
227
374
  configureTransit(config.transitEncryption);
375
+ if (config.transitEncryption && !getTransitSession() && !_transitHandshakePromise) {
376
+ _transitHandshakePromise = (async () => {
377
+ try {
378
+ const session = await createRestTransitSession(config.apiUrl);
379
+ if (session && !getTransitSession()) {
380
+ const algo = typeof globalThis.crypto?.subtle !== "undefined" ? await detectTransitAlgo() : "x25519";
381
+ setTransitSession({ sessionKey: session.sessionKey, algo, sessionId: session.sessionId, enabled: true });
382
+ } else if (!session) {
383
+ configureTransit(false);
384
+ }
385
+ } catch {
386
+ configureTransit(false);
387
+ } finally {
388
+ _transitHandshakePromise = null;
389
+ }
390
+ })();
391
+ }
228
392
  client.interceptors.request.use(async (req) => {
229
393
  const token = _tokenStore?.getAccessToken();
230
394
  if (token) req.headers["Authorization"] = `Bearer ${token}`;
@@ -370,6 +534,13 @@ var storageApi = {
370
534
  async deleteFile(fileId) {
371
535
  await getApiClient().post(`/storage/files/${fileId}/delete`);
372
536
  },
537
+ async completeMultipartUpload(fileId, uploadId, parts) {
538
+ const { data } = await getApiClient().post(
539
+ `/storage/multipart/complete/${fileId}`,
540
+ { uploadId, parts }
541
+ );
542
+ return data;
543
+ },
373
544
  async getConversationFiles(conversationId, params = {}) {
374
545
  const { data } = await getApiClient().get(
375
546
  `/storage/conversations/${conversationId}/files`,
@@ -382,7 +553,52 @@ var storageApi = {
382
553
  return data;
383
554
  }
384
555
  };
385
- async function runUploadBatch(files, platformUploadFn, slotIds, conversationId, onProgress, platformCompressFn, compressionConfig) {
556
+ async function runMultipartUpload(presigned, file, platformUploadPartFn, onProgress) {
557
+ const { multipart } = presigned;
558
+ if (!multipart) throw new Error("No multipart info on presigned response");
559
+ const CONCURRENCY = 3;
560
+ const completedParts = [];
561
+ const partProgress = {};
562
+ multipart.partUrls.forEach(({ partNumber }) => {
563
+ partProgress[partNumber] = 0;
564
+ });
565
+ const reportProgress = () => {
566
+ if (!onProgress) return;
567
+ const vals = Object.values(partProgress);
568
+ const avg = vals.reduce((s, v) => s + v, 0) / Math.max(vals.length, 1);
569
+ onProgress(Math.round(avg * 0.95));
570
+ };
571
+ const uploadPart = async (partNumber, uploadUrl, method) => {
572
+ const offset = (partNumber - 1) * multipart.chunkSize;
573
+ const end = Math.min(offset + multipart.chunkSize, file.size);
574
+ const blob = await fetch(file.uri).then((r) => r.blob());
575
+ const slice = blob.slice(offset, end);
576
+ const etag = await platformUploadPartFn(uploadUrl, slice, (pct) => {
577
+ partProgress[partNumber] = pct;
578
+ reportProgress();
579
+ }, method);
580
+ completedParts.push({ partNumber, etag });
581
+ partProgress[partNumber] = 100;
582
+ reportProgress();
583
+ };
584
+ for (let i = 0; i < multipart.partUrls.length; i += CONCURRENCY) {
585
+ const batch = multipart.partUrls.slice(i, i + CONCURRENCY);
586
+ const results = await Promise.allSettled(
587
+ batch.map(({ partNumber, uploadUrl, method }) => uploadPart(partNumber, uploadUrl, method ?? "PUT"))
588
+ );
589
+ const failed = results.find((r) => r.status === "rejected");
590
+ if (failed) throw failed.reason;
591
+ }
592
+ completedParts.sort((a, b) => a.partNumber - b.partNumber);
593
+ const fileResponse = await storageApi.completeMultipartUpload(
594
+ presigned.fileId,
595
+ multipart.uploadId,
596
+ completedParts
597
+ );
598
+ onProgress?.(100);
599
+ return fileResponse;
600
+ }
601
+ async function runUploadBatch(files, platformUploadFn, slotIds, conversationId, onProgress, platformCompressFn, compressionConfig, platformUploadPartFn) {
386
602
  const compressedFiles = await Promise.all(
387
603
  files.map((f) => compressFile(f, platformCompressFn, compressionConfig ?? { enabled: false, imageQuality: 0.85, imageMaxDimension: 1920, compressDocuments: true }))
388
604
  );
@@ -424,11 +640,19 @@ async function runUploadBatch(files, platformUploadFn, slotIds, conversationId,
424
640
  const { file, slotId } = slotted[originalIdx];
425
641
  progressMap[originalIdx] = 0;
426
642
  try {
427
- await platformUploadFn(presigned, file, (pct) => {
428
- progressMap[originalIdx] = Math.round(pct * 0.9);
429
- reportProgress();
430
- });
431
- const fileResponse = await storageApi.confirmUpload(presigned.fileId);
643
+ let fileResponse;
644
+ if (presigned.multipart && platformUploadPartFn) {
645
+ fileResponse = await runMultipartUpload(presigned, file, platformUploadPartFn, (pct) => {
646
+ progressMap[originalIdx] = pct;
647
+ reportProgress();
648
+ });
649
+ } else {
650
+ await platformUploadFn(presigned, file, (pct) => {
651
+ progressMap[originalIdx] = Math.round(pct * 0.9);
652
+ reportProgress();
653
+ });
654
+ fileResponse = await storageApi.confirmUpload(presigned.fileId);
655
+ }
432
656
  progressMap[originalIdx] = 100;
433
657
  reportProgress();
434
658
  successful.push(fileResponse);
@@ -440,13 +664,13 @@ async function runUploadBatch(files, platformUploadFn, slotIds, conversationId,
440
664
  );
441
665
  return { result: { successful, failed }, slotToFile };
442
666
  }
443
- async function uploadBatch(files, platformUploadFn, conversationId, onProgress, platformCompressFn, compressionConfig) {
667
+ async function uploadBatch(files, platformUploadFn, conversationId, onProgress, platformCompressFn, compressionConfig, platformUploadPartFn) {
444
668
  const slotIds = files.map(() => generateUUID());
445
- const { result } = await runUploadBatch(files, platformUploadFn, slotIds, conversationId, onProgress, platformCompressFn, compressionConfig);
669
+ const { result } = await runUploadBatch(files, platformUploadFn, slotIds, conversationId, onProgress, platformCompressFn, compressionConfig, platformUploadPartFn);
446
670
  return result;
447
671
  }
448
- async function uploadBatchWithSlots(files, platformUploadFn, slotIds, conversationId, onProgress, platformCompressFn, compressionConfig) {
449
- return runUploadBatch(files, platformUploadFn, slotIds, conversationId, onProgress, platformCompressFn, compressionConfig);
672
+ async function uploadBatchWithSlots(files, platformUploadFn, slotIds, conversationId, onProgress, platformCompressFn, compressionConfig, platformUploadPartFn) {
673
+ return runUploadBatch(files, platformUploadFn, slotIds, conversationId, onProgress, platformCompressFn, compressionConfig, platformUploadPartFn);
450
674
  }
451
675
 
452
676
  export {
@@ -456,9 +680,18 @@ export {
456
680
  isTransitEnvelope,
457
681
  configureTransit,
458
682
  setTransitSession,
683
+ getTransitSession,
459
684
  clearTransitSession,
460
685
  isTransitEnabled,
461
686
  getSessionKey,
687
+ getSessionId,
688
+ detectTransitAlgo,
689
+ resetAlgoCache,
690
+ fetchServerKeys,
691
+ generateEphemeralKey,
692
+ createRestTransitSession,
693
+ performHandshake,
694
+ getTransitHandshakePromise,
462
695
  initApiClient,
463
696
  setApiClientInstance,
464
697
  getApiClient,
@@ -467,4 +700,4 @@ export {
467
700
  uploadBatch,
468
701
  uploadBatchWithSlots
469
702
  };
470
- //# sourceMappingURL=chunk-P7VAN6NA.js.map
703
+ //# sourceMappingURL=chunk-ZNA6B2R5.js.map