| 1 | import { |
| 2 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, |
| 3 | WS_METHODS, |
| 4 | type AttachmentCreateUploadUrlInput, |
| 5 | type AttachmentCreateUploadUrlResult, |
| 6 | type AttachmentDeleteInput, |
| 7 | type EnvironmentId, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import type { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | |
| 12 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 13 | import { requestGuarded } from "../rpc/client.ts"; |
| 14 | import { authorizeHubSignedUrl } from "./hubSignedUrl.ts"; |
| 15 | import { |
| 16 | createEnvironmentRpcCommand, |
| 17 | executeAtomQuery, |
| 18 | runAtomCommand, |
| 19 | squashAtomCommandFailure, |
| 20 | type AtomCommand, |
| 21 | } from "./runtime.ts"; |
| 22 | |
| 23 | /** |
| 24 | * RPC commands for pending chat attachment uploads. Mirrors |
| 25 | * `createAssetEnvironmentAtoms`: each client instantiates it with its own |
| 26 | * connection runtime (`attachmentEnvironment` in web and mobile). |
| 27 | */ |
| 28 | export function createAttachmentEnvironmentAtoms<R, E>( |
| 29 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, |
| 30 | ) { |
| 31 | return { |
| 32 | createUploadUrl: createEnvironmentRpcCommand(runtime, { |
| 33 | label: "environment-command:attachments:create-upload-url", |
| 34 | tag: WS_METHODS.attachmentsCreateUploadUrl, |
| 35 | execute: (input) => |
| 36 | requestGuarded(WS_METHODS.attachmentsCreateUploadUrl, input).pipe( |
| 37 | Effect.flatMap(authorizeHubSignedUrl), |
| 38 | ), |
| 39 | }), |
| 40 | remove: createEnvironmentRpcCommand(runtime, { |
| 41 | label: "environment-command:attachments:delete", |
| 42 | tag: WS_METHODS.attachmentsDelete, |
| 43 | }), |
| 44 | }; |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * Whether a failed asset lookup means the attachment no longer exists on the |
| 49 | * server, as opposed to a transient transport failure. Pending uploads expire, |
| 50 | * so this is the signal to upload the bytes again rather than retry the lookup. |
| 51 | * |
| 52 | * A structural `_tag` check rather than a schema check: the squashed cause of |
| 53 | * a failed RPC is not guaranteed to be a decoded error class instance, only a |
| 54 | * tagged value. |
| 55 | */ |
| 56 | export function isAssetAttachmentNotFoundFailure(error: unknown): boolean { |
| 57 | return ( |
| 58 | typeof error === "object" && |
| 59 | error !== null && |
| 60 | "_tag" in error && |
| 61 | error._tag === "AssetAttachmentNotFoundError" |
| 62 | ); |
| 63 | } |
| 64 | |
| 65 | export type PersistedAttachmentVerification = |
| 66 | | { readonly status: "verified" } |
| 67 | | { readonly status: "missing" } |
| 68 | | { readonly status: "failed"; readonly error: unknown }; |
| 69 | |
| 70 | /** |
| 71 | * Checks that a previously uploaded pending attachment still exists on the |
| 72 | * server by minting an asset URL for it. `verified` means the send can reuse |
| 73 | * the stored bytes, `missing` means the upload expired and the bytes must be |
| 74 | * uploaded again, `failed` means the server could not be asked. |
| 75 | */ |
| 76 | export async function verifyPersistedAttachmentUpload<A, E>(input: { |
| 77 | readonly registry: AtomRegistry.AtomRegistry; |
| 78 | readonly createAssetUrl: (query: { |
| 79 | readonly environmentId: EnvironmentId; |
| 80 | readonly input: { |
| 81 | readonly resource: { readonly _tag: "attachment"; readonly attachmentId: string }; |
| 82 | }; |
| 83 | }) => Atom.Atom<AsyncResult.AsyncResult<A, E>>; |
| 84 | readonly environmentId: EnvironmentId; |
| 85 | readonly attachmentId: string; |
| 86 | }): Promise<PersistedAttachmentVerification> { |
| 87 | const result = await executeAtomQuery( |
| 88 | input.registry, |
| 89 | input.createAssetUrl({ |
| 90 | environmentId: input.environmentId, |
| 91 | input: { resource: { _tag: "attachment", attachmentId: input.attachmentId } }, |
| 92 | }), |
| 93 | // `refresh` forces a server round trip: the asset URL query atom caches |
| 94 | // results (SWR), so a retry right after a transient failure would |
| 95 | // otherwise re-observe the cached failure and never ask the server. |
| 96 | { reportFailure: false, reportDefect: false, refresh: true }, |
| 97 | ); |
| 98 | if (result._tag === "Success") { |
| 99 | return { status: "verified" }; |
| 100 | } |
| 101 | const error = squashAtomCommandFailure(result); |
| 102 | return isAssetAttachmentNotFoundFailure(error) |
| 103 | ? { status: "missing" } |
| 104 | : { status: "failed", error }; |
| 105 | } |
| 106 | |
| 107 | type AttachmentCreateUploadUrlCommand<E> = AtomCommand< |
| 108 | { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput }, |
| 109 | AttachmentCreateUploadUrlResult, |
| 110 | E |
| 111 | >; |
| 112 | |
| 113 | type AttachmentRemoveCommand<E> = AtomCommand< |
| 114 | { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput }, |
| 115 | unknown, |
| 116 | E |
| 117 | >; |
| 118 | |
| 119 | /** Fire-and-forget delete of a pending upload the client no longer references. */ |
| 120 | export function deletePendingAttachmentUpload<E>(input: { |
| 121 | readonly registry: AtomRegistry.AtomRegistry; |
| 122 | readonly remove: AttachmentRemoveCommand<E>; |
| 123 | readonly environmentId: EnvironmentId; |
| 124 | readonly attachmentId: string; |
| 125 | }): void { |
| 126 | void runAtomCommand( |
| 127 | input.registry, |
| 128 | input.remove, |
| 129 | { environmentId: input.environmentId, input: { attachmentId: input.attachmentId } }, |
| 130 | { reportFailure: false, reportDefect: false }, |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | /** A running byte transfer: resolves when the server accepted the bytes. */ |
| 135 | export interface AttachmentByteUpload { |
| 136 | readonly done: Promise<void>; |
| 137 | readonly abort: () => void; |
| 138 | } |
| 139 | |
| 140 | export type AttachmentUploadCycleResult = |
| 141 | | { readonly status: "uploaded"; readonly attachmentId: string } |
| 142 | | { readonly status: "cancelled"; readonly attachmentId: string | null } |
| 143 | | { |
| 144 | readonly status: "failed"; |
| 145 | readonly step: "mint" | "resolve-url" | "transfer"; |
| 146 | readonly attachmentId: string | null; |
| 147 | readonly error: unknown; |
| 148 | }; |
| 149 | |
| 150 | /** |
| 151 | * The platform-neutral upload cycle: mint a signed upload URL, resolve it |
| 152 | * against the environment's HTTP base, and hand the bytes to a |
| 153 | * platform-specific transport (XHR on web, `expo-file-system` on mobile). |
| 154 | * |
| 155 | * The cycle never deletes the minted pending upload on failure: callers keep |
| 156 | * the returned `attachmentId` and decide between retry and release. The one |
| 157 | * exception is `onMinted` returning `"cancel"`, where the caller already |
| 158 | * abandoned the upload and the fresh mint is deleted before returning. |
| 159 | */ |
| 160 | export async function runAttachmentUploadCycle<E, RE>(input: { |
| 161 | readonly registry: AtomRegistry.AtomRegistry; |
| 162 | readonly createUploadUrl: AttachmentCreateUploadUrlCommand<E>; |
| 163 | readonly remove: AttachmentRemoveCommand<RE>; |
| 164 | readonly environmentId: EnvironmentId; |
| 165 | readonly upload: AttachmentCreateUploadUrlInput; |
| 166 | readonly resolveUploadUrl: (relativeUrl: string) => string | null; |
| 167 | readonly transport: (url: string) => AttachmentByteUpload; |
| 168 | /** Observe the minted id (for cancellation bookkeeping) before bytes move. */ |
| 169 | readonly onMinted?: (attachmentId: string) => "continue" | "cancel"; |
| 170 | readonly onTransferStart?: (abort: () => void) => void; |
| 171 | }): Promise<AttachmentUploadCycleResult> { |
| 172 | const minted = await runAtomCommand( |
| 173 | input.registry, |
| 174 | input.createUploadUrl, |
| 175 | { environmentId: input.environmentId, input: input.upload }, |
| 176 | { reportFailure: false }, |
| 177 | ); |
| 178 | if (minted._tag !== "Success") { |
| 179 | return { |
| 180 | status: "failed", |
| 181 | step: "mint", |
| 182 | attachmentId: null, |
| 183 | error: squashAtomCommandFailure(minted), |
| 184 | }; |
| 185 | } |
| 186 | const attachmentId = minted.value.attachmentId; |
| 187 | if (input.onMinted?.(attachmentId) === "cancel") { |
| 188 | deletePendingAttachmentUpload({ |
| 189 | registry: input.registry, |
| 190 | remove: input.remove, |
| 191 | environmentId: input.environmentId, |
| 192 | attachmentId, |
| 193 | }); |
| 194 | return { status: "cancelled", attachmentId }; |
| 195 | } |
| 196 | |
| 197 | const url = input.resolveUploadUrl(minted.value.relativeUrl); |
| 198 | if (!url) { |
| 199 | return { |
| 200 | status: "failed", |
| 201 | step: "resolve-url", |
| 202 | attachmentId, |
| 203 | error: new Error("The environment is not connected."), |
| 204 | }; |
| 205 | } |
| 206 | |
| 207 | const transfer = input.transport(url); |
| 208 | input.onTransferStart?.(transfer.abort); |
| 209 | try { |
| 210 | await transfer.done; |
| 211 | } catch (error) { |
| 212 | return { status: "failed", step: "transfer", attachmentId, error }; |
| 213 | } |
| 214 | return { status: "uploaded", attachmentId }; |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * The effective per-file byte limit for a server that advertises |
| 219 | * `capabilities.fileAttachments.maxUploadBytes`. The contract caps what a |
| 220 | * turn may reference, so a larger advertised value must not admit files the |
| 221 | * send would then refuse. |
| 222 | */ |
| 223 | export function clampFileAttachmentUploadBytes(advertisedMaxUploadBytes: number): number { |
| 224 | return Math.min(advertisedMaxUploadBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES); |
| 225 | } |
| 226 | |
| 227 | /** "3.2 MB" / "48 KB" label for attachment rows. Never shows "0 KB". */ |
| 228 | export function formatAttachmentSize(sizeBytes: number): string { |
| 229 | return sizeBytes >= 1024 * 1024 |
| 230 | ? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB` |
| 231 | : `${Math.max(1, Math.ceil(sizeBytes / 1024))} KB`; |
| 232 | } |
| 233 | |
| 234 | /** User-facing rejection for a file over the effective upload limit. */ |
| 235 | export function fileAttachmentTooLargeMessage(name: string, maxUploadBytes: number): string { |
| 236 | const maxUploadSize = |
| 237 | maxUploadBytes >= 1024 * 1024 && maxUploadBytes % (1024 * 1024) === 0 |
| 238 | ? `${maxUploadBytes / (1024 * 1024)} MB` |
| 239 | : maxUploadBytes >= 1024 && maxUploadBytes % 1024 === 0 |
| 240 | ? `${maxUploadBytes / 1024} KB` |
| 241 | : `${maxUploadBytes} ${maxUploadBytes === 1 ? "byte" : "bytes"}`; |
| 242 | return `'${name}' exceeds the ${maxUploadSize} attachment limit.`; |
| 243 | } |