Skip to content
Source
Sign inSign up

attachmentUpload.ts

406 lines14.8 KB

1import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
2import {
3 isAssetAttachmentNotFoundFailure,
4 runAttachmentUploadCycle,
5 verifyPersistedAttachmentUpload,
6} from "@t3tools/client-runtime/state/attachments";
7import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
8import type {
9 ChatFileAttachment,
10 ChatImageAttachment,
11 EnvironmentId,
12 UploadChatImageAttachment,
13} from "@t3tools/contracts";
14import {
15 AuthOrchestrationOperateScope,
16 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,
17} from "@t3tools/contracts";
18import * as Option from "effect/Option";
19​
20import { appAtomRegistry } from "../state/atom-registry";
21import { assetEnvironment } from "../state/assets";
22import { attachmentEnvironment } from "../state/attachments";
23import { environmentSession, readEnvironmentScope } from "../state/session";
24import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
25import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention";
26import {
27 isComposerImageAttachment,
28 isFileBackedComposerAttachment,
29 type DraftComposerAttachment,
30 type DraftComposerImageAttachment,
31} from "./composerImages";
32import { imageMimeType } from "@t3tools/shared/image";
33import { uuidv4 } from "./uuid";
34​
35export type UploadedMobileAttachment =
36 | UploadChatImageAttachment
37 | ChatImageAttachment
38 | ChatFileAttachment;
39​
40export function withUploadedMobileAttachmentReferences(input: {
41 readonly environmentId: EnvironmentId;
42 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
43 readonly uploadedAttachments: ReadonlyArray<UploadedMobileAttachment>;
44}): ReadonlyArray<DraftComposerAttachment> {
45 return input.attachments.map((attachment, index) => {
46 const uploaded = input.uploadedAttachments[index];
47 // Files-picked images upload as images; compare wire types to retain uploaded ids and avoid re-uploading.
48 const uploadedAs = isComposerImageAttachment(attachment) ? "image" : attachment.type;
49 if (
50 !uploaded ||
51 !("id" in uploaded) ||
52 uploadedAs !== uploaded.type ||
53 (attachment.uploadedAttachmentId === uploaded.id &&
54 attachment.uploadEnvironmentId === input.environmentId)
55 ) {
56 return attachment;
57 }
58 return {
59 ...attachment,
60 uploadedAttachmentId: uploaded.id,
61 uploadEnvironmentId: input.environmentId,
62 };
63 });
65​
66export async function releasePendingAttachmentUploads(
67 environmentId: EnvironmentId,
68 attachmentIds: ReadonlyArray<string>,
69): Promise<void> {
70 const deleteOnce = async (attachmentId: string): Promise<boolean> => {
71 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
72 throw new Error("This connection cannot delete pending attachments.");
73 }
74 const result = await runAtomCommand(
75 appAtomRegistry,
76 attachmentEnvironment.remove,
77 { environmentId, input: { attachmentId } },
78 { reportFailure: false, reportDefect: false },
79 );
80 return (
81 result._tag === "Success" ||
82 isAssetAttachmentNotFoundFailure(squashAtomCommandFailure(result))
83 );
84 };
85​
86 const failedAttachmentIds: string[] = [];
87 for (const attachmentId of attachmentIds) {
88 if (!(await deleteOnce(attachmentId)) && !(await deleteOnce(attachmentId))) {
89 failedAttachmentIds.push(attachmentId);
90 }
91 }
92 if (failedAttachmentIds.length > 0) {
93 throw new Error(
94 `Could not delete ${failedAttachmentIds.length} pending attachment upload(s): ${failedAttachmentIds.join(", ")}.`,
95 );
96 }
98​
99async function releaseCreatedUploadsQuietly(
100 environmentId: EnvironmentId,
101 attachmentIds: ReadonlyArray<string>,
102): Promise<void> {
103 try {
104 await releasePendingAttachmentUploads(environmentId, attachmentIds);
105 } catch (error) {
106 console.warn("[attachments] could not delete abandoned pending uploads", error);
107 }
109​
110export interface PreparedTurnAttachments {
111 readonly status: "ready";
112 readonly attachments: ReadonlyArray<UploadedMobileAttachment>;
113 readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;
114 readonly pendingAttachmentIds: ReadonlyArray<string>;
116​
117export type PrepareTurnAttachmentsResult =
118 | PreparedTurnAttachments
119 | { readonly status: "abandoned" };
120​
121/** Promote Files-picked images consistently in headers, payloads and references or ChatImageAttachment rejects them. */
122function composerAttachmentWireMimeType(attachment: DraftComposerAttachment): string {
123 if (!isComposerImageAttachment(attachment)) return attachment.mimeType;
124 return supportedImageWireMimeType(attachment);
126​
127function supportedImageWireMimeType(
128 attachment: DraftComposerAttachment,
129): (typeof PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES)[number] {
130 const inferred = imageMimeType(attachment);
131 const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find(
132 (type) => type === attachment.mimeType.toLowerCase() || type === inferred,
133 );
134 if (!mimeType) throw new Error(`Unsupported image type for '${attachment.name}'.`);
135 return mimeType;
137​
138function uploadedReference(
139 attachment: DraftComposerAttachment,
140 id: string,
141): ChatImageAttachment | ChatFileAttachment {
142 const fields = {
143 id,
144 name: attachment.name,
145 mimeType: composerAttachmentWireMimeType(attachment),
146 sizeBytes: attachment.sizeBytes,
147 };
148 return isComposerImageAttachment(attachment)
149 ? { type: "image", ...fields }
150 : {
151 type: "file",
152 ...fields,
153 ...(attachment.source ? { source: attachment.source } : {}),
154 };
156​
157function attachmentUploadInput(attachment: DraftComposerAttachment) {
158 const fields = { name: attachment.name, sizeBytes: attachment.sizeBytes };
159 return isComposerImageAttachment(attachment)
160 ? { ...fields, mimeType: supportedImageWireMimeType(attachment) }
161 : { type: "file" as const, ...fields, mimeType: attachment.mimeType };
163​
164async function toUploadChatImageAttachments(
165 attachments: ReadonlyArray<DraftComposerImageAttachment>,
166): Promise<ReadonlyArray<UploadChatImageAttachment>> {
167 return Promise.all(
168 attachments.map(async (attachment) => ({
169 type: attachment.type,
170 name: attachment.name,
171 mimeType: attachment.mimeType,
172 sizeBytes: attachment.sizeBytes,
173 dataUrl: await composerImageAttachmentDataUrl(attachment),
174 })),
175 );
177​
178async function composerImageAttachmentDataUrl(
179 attachment: DraftComposerImageAttachment,
180): Promise<string> {
181 if (attachment.dataUrl !== undefined) {
182 return attachment.dataUrl;
183 }
184 if (!isFileBackedComposerAttachment(attachment)) {
185 throw new Error(`'${attachment.name}' is no longer available. Attach the image again.`);
186 }
187 const release = retainComposerAttachmentFileForPreview(attachment);
188 try {
189 const { File, Paths } = await import("expo-file-system");
190 const uri =
191 resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??
192 attachment.fileUri;
193 const base64 = await new File(uri).base64();
194 return `data:${attachment.mimeType};base64,${base64}`;
195 } finally {
196 release();
197 }
199​
200async function uploadFileBytes(
201 environmentId: EnvironmentId,
202 attachment: DraftComposerAttachment,
203 url: string,
204 signal: AbortSignal,
205 onProgress?: (progress: number) => void,
206): Promise<void> {
207 const { File, Paths, UploadType } = await import("expo-file-system");
208 if (signal.aborted) throw new Error("Upload cancelled.");
209 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
210 throw new Error("This connection cannot upload attachments.");
211 }
212 const fileUri = attachment.fileUri;
213 const inlineDataUrl = attachment.type === "image" ? attachment.dataUrl : undefined;
214 if (fileUri === undefined && inlineDataUrl === undefined) {
215 throw new Error(`'${attachment.name}' is no longer available. Attach the image again.`);
216 }
217 const file =
218 fileUri === undefined
219 ? new File(Paths.cache, `t3-upload-${uuidv4()}`)
220 : new File(resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri) ?? fileUri);
221 try {
222 if (fileUri === undefined && inlineDataUrl !== undefined) {
223 file.create();
224 await file.write(inlineDataUrl.slice(inlineDataUrl.indexOf(",") + 1), {
225 encoding: "base64",
226 });
227 }
228 const result = await file.upload(url, {
229 httpMethod: "POST",
230 uploadType: UploadType.BINARY_CONTENT,
231 headers: { "Content-Type": composerAttachmentWireMimeType(attachment) },
232 signal,
233 ...(onProgress
234 ? {
235 // Native events carry Int64 counts, which expo-modules-jsi delivers as BigInt despite the `number` type.
236 onProgress: ({ bytesSent, totalBytes }) => {
237 const total = Number(totalBytes);
238 if (total > 0) onProgress(Number(bytesSent) / total);
239 },
240 }
241 : {}),
242 });
243 if (result.status < 200 || result.status >= 300) {
244 throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`);
245 }
246 } finally {
247 if (fileUri === undefined && file.exists) file.delete();
248 }
250​
251/** persistUploadedReferences runs only for new uploaded ids; save them to their owner so crash retries reuse bytes. */
252export async function prepareTurnAttachments(input: {
253 readonly environmentId: EnvironmentId;
254 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
255 readonly supportsImageUploads?: boolean;
256 readonly signal?: AbortSignal;
257 readonly onUploadProgress?: (attachmentId: string, progress: number) => void;
258 readonly persistUploadedReferences?: (
259 draftAttachments: ReadonlyArray<DraftComposerAttachment>,
260 ) => Promise<"persisted" | "abandon">;
261}): Promise<PrepareTurnAttachmentsResult> {
262 const { environmentId } = input;
263 if (input.signal?.aborted) return { status: "abandoned" };
264 const files = input.attachments.filter((attachment) => attachment.type === "file");
265 const ready = (
266 attachments: ReadonlyArray<UploadedMobileAttachment>,
267 pendingAttachmentIds: ReadonlyArray<string>,
268 draftAttachments: ReadonlyArray<DraftComposerAttachment>,
269 ): PreparedTurnAttachments => ({
270 status: "ready",
271 attachments,
272 draftAttachments,
273 pendingAttachmentIds,
274 });
275​
276 if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) {
277 try {
278 const imageAttachments = await toUploadChatImageAttachments(
279 input.attachments.filter((attachment) => attachment.type === "image"),
280 );
281 if (input.signal?.aborted) return { status: "abandoned" };
282 return ready(imageAttachments, [], input.attachments);
283 } catch (error) {
284 if (input.signal?.aborted) return { status: "abandoned" };
285 throw error;
286 }
287 }
288​
289 const requireUploadAccess = () => {
290 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
291 throw new Error("This connection cannot upload attachments.");
292 }
293 };
294 requireUploadAccess();
295​
296 const connection = appAtomRegistry.get(
297 environmentSession.preparedConnectionValueAtom(environmentId),
298 );
299 if (Option.isNone(connection)) {
300 throw new Error("The environment is not connected.");
301 }
302​
303 const uploadedAttachments: UploadedMobileAttachment[] = [];
304 const pendingAttachmentIds: string[] = [];
305 const createdAttachmentIds: string[] = [];
306 const controller = new AbortController();
307 const abort = () => controller.abort();
308 input.signal?.addEventListener("abort", abort, { once: true });
309 try {
310 for (const attachment of input.attachments) {
311 if (controller.signal.aborted) throw new Error("Upload cancelled.");
312 requireUploadAccess();
313 if (attachment.type === "image" && !input.supportsImageUploads) {
314 uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment])));
315 continue;
316 }
317​
318 if (
319 attachment.uploadEnvironmentId === environmentId &&
320 attachment.uploadedAttachmentId !== undefined
321 ) {
322 const verification = await verifyPersistedAttachmentUpload({
323 registry: appAtomRegistry,
324 createAssetUrl: assetEnvironment.createUrl,
325 environmentId,
326 attachmentId: attachment.uploadedAttachmentId,
327 });
328 if (verification.status === "failed") {
329 throw verification.error;
330 }
331 if (verification.status === "verified") {
332 pendingAttachmentIds.push(attachment.uploadedAttachmentId);
333 uploadedAttachments.push(uploadedReference(attachment, attachment.uploadedAttachmentId));
334 continue;
335 }
336 }
337​
338 requireUploadAccess();
339 const result = await runAttachmentUploadCycle({
340 registry: appAtomRegistry,
341 createUploadUrl: attachmentEnvironment.createUploadUrl,
342 remove: attachmentEnvironment.remove,
343 environmentId,
344 upload: attachmentUploadInput(attachment),
345 // Resolve at transfer time; reconnects may change the base URL.
346 resolveUploadUrl: (relativeUrl) => {
347 requireUploadAccess();
348 const currentConnection = appAtomRegistry.get(
349 environmentSession.preparedConnectionValueAtom(environmentId),
350 );
351 return Option.isNone(currentConnection)
352 ? null
353 : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl);
354 },
355 transport: (url) => ({
356 done: uploadFileBytes(
357 environmentId,
358 attachment,
359 url,
360 controller.signal,
361 input.onUploadProgress
362 ? (progress) => input.onUploadProgress?.(attachment.id, progress)
363 : undefined,
364 ),
365 abort,
366 }),
367 onMinted: (attachmentId) => {
368 if (controller.signal.aborted) return "cancel";
369 pendingAttachmentIds.push(attachmentId);
370 createdAttachmentIds.push(attachmentId);
371 return "continue";
372 },
373 });
374 if (result.status !== "uploaded") {
375 throw result.status === "failed" && result.error !== undefined
376 ? result.error
377 : new Error(`Upload failed for '${attachment.name}'.`);
378 }
379 uploadedAttachments.push(uploadedReference(attachment, result.attachmentId));
380 }
381​
382 if (controller.signal.aborted) throw new Error("Upload cancelled.");
383​
384 const draftAttachments = withUploadedMobileAttachmentReferences({
385 environmentId,
386 attachments: input.attachments,
387 uploadedAttachments,
388 });
389 const referencesChanged = draftAttachments.some(
390 (attachment, index) => attachment !== input.attachments[index],
391 );
392 if (referencesChanged && input.persistUploadedReferences) {
393 if ((await input.persistUploadedReferences(draftAttachments)) === "abandon") {
394 await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds);
395 return { status: "abandoned" };
396 }
397 }
398 return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments);
399 } catch (error) {
400 await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds);
401 if (controller.signal.aborted) return { status: "abandoned" };
402 throw error;
403 } finally {
404 input.signal?.removeEventListener("abort", abort);
405 }