432 lines · 16.5 KB · TypeScript
RawDownload
import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";import {  clampFileAttachmentUploadBytes,  fileAttachmentTooLargeMessage,  isAssetAttachmentNotFoundFailure,  runAttachmentUploadCycle,  verifyPersistedAttachmentUpload,} from "@t3tools/client-runtime/state/attachments";import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";import type {  ChatFileAttachment,  ChatImageAttachment,  EnvironmentId,  UploadChatImageAttachment,} from "@t3tools/contracts";import { PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES } from "@t3tools/contracts";import * as Option from "effect/Option"; import { appAtomRegistry } from "../state/atom-registry";import { assetEnvironment } from "../state/assets";import { attachmentEnvironment } from "../state/attachments";import { environmentSession } from "../state/session";import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";import {  isFileBackedComposerAttachment,  type DraftComposerAttachment,  type DraftComposerImageAttachment,} from "./composerImages";import { uuidv4 } from "./uuid"; /** * This module owns the server side of a composer attachment's lifecycle. * `prepareTurnAttachments` acquires pending uploads (verifying and reusing * persisted ones), hands the uploaded ids back to the attachment's durable * owner (queued outbox message or composer draft), and leaves their cleanup * to that owner after it checks shared references. Nothing outside this module * mints or deletes pending uploads. The local-file side of the lifecycle is * owned by `removeThreadOutboxMessage` / the composer draft mutators, which * release files through `releaseUnusedComposerAttachmentFiles`. */export type UploadedMobileAttachment =  | UploadChatImageAttachment  | ChatImageAttachment  | ChatFileAttachment; export function validateDraftFileAttachments(input: {  readonly attachments: ReadonlyArray<DraftComposerAttachment>;  readonly serverConfig: {    readonly environment: {      readonly capabilities: {        readonly attachmentUploads?: boolean;        readonly fileAttachments?: { readonly maxUploadBytes: number };      };    };  } | null;}): string | null {  const files = input.attachments.filter((attachment) => attachment.type === "file");  if (files.length === 0) return null;  if (input.serverConfig === null) return "Server attachment support is still loading.";  const capabilities = input.serverConfig.environment.capabilities;  if (capabilities.attachmentUploads !== true || capabilities.fileAttachments === undefined) {    return "This server does not support file attachments.";  }  const maxBytes = clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes);  const oversized = files.find((attachment) => attachment.sizeBytes > maxBytes);  return oversized ? fileAttachmentTooLargeMessage(oversized.name, maxBytes) : null;} /** Keep uploaded ids alongside the local bytes so a later send can reuse them. */export function withUploadedMobileAttachmentReferences(input: {  readonly environmentId: EnvironmentId;  readonly attachments: ReadonlyArray<DraftComposerAttachment>;  readonly uploadedAttachments: ReadonlyArray<UploadedMobileAttachment>;}): ReadonlyArray<DraftComposerAttachment> {  return input.attachments.map((attachment, index) => {    const uploaded = input.uploadedAttachments[index];    if (      !uploaded ||      !("id" in uploaded) ||      attachment.type !== uploaded.type ||      (attachment.uploadedAttachmentId === uploaded.id &&        attachment.uploadEnvironmentId === input.environmentId)    ) {      return attachment;    }    return {      ...attachment,      uploadedAttachmentId: uploaded.id,      uploadEnvironmentId: input.environmentId,    };  });} /** * Deletes pending uploads the client no longer references. Every delete result * is inspected; failed deletes are retried once and a persistent failure * throws, so a caller can never silently leak the outcome. (The server also * expires pending uploads, so a leaked id self-heals eventually.) */export async function releasePendingAttachmentUploads(  environmentId: EnvironmentId,  attachmentIds: ReadonlyArray<string>,): Promise<void> {  const deleteOnce = async (attachmentId: string): Promise<boolean> => {    const result = await runAtomCommand(      appAtomRegistry,      attachmentEnvironment.remove,      { environmentId, input: { attachmentId } },      { reportFailure: false, reportDefect: false },    );    return (      result._tag === "Success" ||      isAssetAttachmentNotFoundFailure(squashAtomCommandFailure(result))    );  };   const failedAttachmentIds: string[] = [];  for (const attachmentId of attachmentIds) {    if (!(await deleteOnce(attachmentId)) && !(await deleteOnce(attachmentId))) {      failedAttachmentIds.push(attachmentId);    }  }  if (failedAttachmentIds.length > 0) {    throw new Error(      `Could not delete ${failedAttachmentIds.length} pending attachment upload(s): ${failedAttachmentIds.join(", ")}.`,    );  }} async function releaseCreatedUploadsQuietly(  environmentId: EnvironmentId,  attachmentIds: ReadonlyArray<string>,): Promise<void> {  try {    await releasePendingAttachmentUploads(environmentId, attachmentIds);  } catch (error) {    // The original failure must propagate; the leaked pending uploads expire    // on the server.    console.warn("[attachments] could not delete abandoned pending uploads", error);  }} export interface PreparedTurnAttachments {  readonly status: "ready";  /** Wire attachments for `startTurn`, in the original composer order. */  readonly attachments: ReadonlyArray<UploadedMobileAttachment>;  /** Composer attachments annotated with the uploaded pending ids. */  readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;  /** Every pending upload backing this turn (reused and newly minted). */  readonly pendingAttachmentIds: ReadonlyArray<string>;} export type PrepareTurnAttachmentsResult =  | PreparedTurnAttachments  | { readonly status: "abandoned" }; function uploadedReference(  attachment: DraftComposerAttachment,  id: string,): ChatImageAttachment | ChatFileAttachment {  const fields = {    id,    name: attachment.name,    mimeType: attachment.mimeType,    sizeBytes: attachment.sizeBytes,  };  return attachment.type === "image" ? { type: "image", ...fields } : { type: "file", ...fields };} function attachmentUploadInput(attachment: DraftComposerAttachment) {  const fields = {    name: attachment.name,    mimeType: attachment.mimeType,    sizeBytes: attachment.sizeBytes,  };  if (attachment.type === "file") return { type: "file" as const, ...fields };  const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find(    (type) => type === attachment.mimeType.toLowerCase(),  );  if (!mimeType) throw new Error(`Unsupported image type for '${attachment.name}'.`);  return { ...fields, mimeType };} /** * Wire shape for startTurn on servers without attachment uploads: pure inline * uploads without client draft id / previewUri. File-backed images read their * base64 from disk lazily, only when this legacy path is actually taken. */async function toUploadChatImageAttachments(  attachments: ReadonlyArray<DraftComposerImageAttachment>,): Promise<ReadonlyArray<UploadChatImageAttachment>> {  return Promise.all(    attachments.map(async (attachment) => ({      type: attachment.type,      name: attachment.name,      mimeType: attachment.mimeType,      sizeBytes: attachment.sizeBytes,      dataUrl: await composerImageAttachmentDataUrl(attachment),    })),  );} /** Inline bytes for one image: legacy drafts carry them, file-backed ones read them from disk. */async function composerImageAttachmentDataUrl(  attachment: DraftComposerImageAttachment,): Promise<string> {  if (attachment.dataUrl !== undefined) {    return attachment.dataUrl;  }  if (!isFileBackedComposerAttachment(attachment)) {    throw new Error(`'${attachment.name}' is no longer available. Attach the image again.`);  }  const release = retainComposerAttachmentFileForPreview(attachment);  try {    const { File, Paths } = await import("expo-file-system");    const uri =      resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??      attachment.fileUri;    const base64 = await new File(uri).base64();    return `data:${attachment.mimeType};base64,${base64}`;  } finally {    release();  }} async function uploadFileBytes(  attachment: DraftComposerAttachment,  url: string,  signal: AbortSignal,  onProgress?: (progress: number) => void,): Promise<void> {  const { File, Paths, UploadType } = await import("expo-file-system");  if (signal.aborted) throw new Error("Upload cancelled.");  // Legacy image drafts persisted inline bytes and stage them in a temp cache  // file for the native uploader. Everything else uploads its owned copy.  const fileUri = attachment.fileUri;  const inlineDataUrl = attachment.type === "image" ? attachment.dataUrl : undefined;  if (fileUri === undefined && inlineDataUrl === undefined) {    throw new Error(`'${attachment.name}' is no longer available. Attach the image again.`);  }  const file =    fileUri === undefined      ? new File(Paths.cache, `t3-upload-${uuidv4()}`)      : new File(resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri) ?? fileUri);  try {    if (fileUri === undefined && inlineDataUrl !== undefined) {      file.create();      file.write(inlineDataUrl.slice(inlineDataUrl.indexOf(",") + 1), {        encoding: "base64",      });    }    const result = await file.upload(url, {      httpMethod: "POST",      uploadType: UploadType.BINARY_CONTENT,      headers: { "Content-Type": attachment.mimeType },      signal,      ...(onProgress        ? {            onProgress: ({ bytesSent, totalBytes }) => {              if (totalBytes > 0) onProgress(bytesSent / totalBytes);            },          }        : {}),    });    if (result.status < 200 || result.status >= 300) {      throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`);    }  } finally {    if (fileUri === undefined && file.exists) file.delete();  }} /** * Acquires server-side uploads for one turn's attachments and persists the * uploaded ids into the attachments' durable owner. * * `persistUploadedReferences` runs once the bytes are on the server and only * when new ids appeared. It must write the annotated attachments into the * owner (queued message or draft) so a retry after a crash reuses the bytes. * Returning `"abandon"` (owner no longer wants the send) or throwing deletes * the pending uploads this call minted, so the owner cannot leak them. */export async function prepareTurnAttachments(input: {  readonly environmentId: EnvironmentId;  readonly attachments: ReadonlyArray<DraftComposerAttachment>;  /** Older environments continue to receive inline images. */  readonly supportsImageUploads?: boolean;  readonly signal?: AbortSignal;  readonly onUploadProgress?: (attachmentId: string, progress: number) => void;  readonly persistUploadedReferences?: (    draftAttachments: ReadonlyArray<DraftComposerAttachment>,  ) => Promise<"persisted" | "abandon">;}): Promise<PrepareTurnAttachmentsResult> {  const { environmentId } = input;  if (input.signal?.aborted) return { status: "abandoned" };  const files = input.attachments.filter((attachment) => attachment.type === "file");  const ready = (    attachments: ReadonlyArray<UploadedMobileAttachment>,    pendingAttachmentIds: ReadonlyArray<string>,    draftAttachments: ReadonlyArray<DraftComposerAttachment>,  ): PreparedTurnAttachments => ({    status: "ready",    attachments,    draftAttachments,    pendingAttachmentIds,  });   if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) {    try {      const imageAttachments = await toUploadChatImageAttachments(        input.attachments.filter((attachment) => attachment.type === "image"),      );      if (input.signal?.aborted) return { status: "abandoned" };      return ready(imageAttachments, [], input.attachments);    } catch (error) {      if (input.signal?.aborted) return { status: "abandoned" };      throw error;    }  }   const connection = appAtomRegistry.get(    environmentSession.preparedConnectionValueAtom(environmentId),  );  if (Option.isNone(connection)) {    throw new Error("The environment is not connected.");  }   const uploadedAttachments: UploadedMobileAttachment[] = [];  const pendingAttachmentIds: string[] = [];  const createdAttachmentIds: string[] = [];  const controller = new AbortController();  const abort = () => controller.abort();  input.signal?.addEventListener("abort", abort, { once: true });  try {    for (const attachment of input.attachments) {      if (controller.signal.aborted) throw new Error("Upload cancelled.");      if (attachment.type === "image" && !input.supportsImageUploads) {        uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment])));        continue;      }       // Reuse the bytes from a previous attempt when their pending upload is      // still alive on this environment.      if (        attachment.uploadEnvironmentId === environmentId &&        attachment.uploadedAttachmentId !== undefined      ) {        const verification = await verifyPersistedAttachmentUpload({          registry: appAtomRegistry,          createAssetUrl: assetEnvironment.createUrl,          environmentId,          attachmentId: attachment.uploadedAttachmentId,        });        if (verification.status === "failed") {          throw verification.error;        }        if (verification.status === "verified") {          pendingAttachmentIds.push(attachment.uploadedAttachmentId);          uploadedAttachments.push(uploadedReference(attachment, attachment.uploadedAttachmentId));          continue;        }        // "missing": the pending upload expired, upload the bytes again.      }       const result = await runAttachmentUploadCycle({        registry: appAtomRegistry,        createUploadUrl: attachmentEnvironment.createUploadUrl,        remove: attachmentEnvironment.remove,        environmentId,        upload: attachmentUploadInput(attachment),        // Read the connection at transfer time: the environment may have        // reconnected on a new base URL since this cycle started.        resolveUploadUrl: (relativeUrl) => {          const currentConnection = appAtomRegistry.get(            environmentSession.preparedConnectionValueAtom(environmentId),          );          return Option.isNone(currentConnection)            ? null            : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl);        },        transport: (url) => ({          done: uploadFileBytes(            attachment,            url,            controller.signal,            input.onUploadProgress              ? (progress) => input.onUploadProgress?.(attachment.id, progress)              : undefined,          ),          abort,        }),        onMinted: (attachmentId) => {          if (controller.signal.aborted) return "cancel";          pendingAttachmentIds.push(attachmentId);          createdAttachmentIds.push(attachmentId);          return "continue";        },      });      if (result.status !== "uploaded") {        throw result.status === "failed" && result.error !== undefined          ? result.error          : new Error(`Upload failed for '${attachment.name}'.`);      }      uploadedAttachments.push(uploadedReference(attachment, result.attachmentId));    }     if (controller.signal.aborted) throw new Error("Upload cancelled.");     const draftAttachments = withUploadedMobileAttachmentReferences({      environmentId,      attachments: input.attachments,      uploadedAttachments,    });    const referencesChanged = draftAttachments.some(      (attachment, index) => attachment !== input.attachments[index],    );    if (referencesChanged && input.persistUploadedReferences) {      if ((await input.persistUploadedReferences(draftAttachments)) === "abandon") {        await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds);        return { status: "abandoned" };      }    }    return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments);  } catch (error) {    await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds);    if (controller.signal.aborted) return { status: "abandoned" };    throw error;  } finally {    input.signal?.removeEventListener("abort", abort);  }}