/** * Upload base64 bytes via a Dropbox upload session (start → append → finish). * Prefer this (or `./upload`, which auto-delegates above 120 KiB binary) for * larger payloads. Put `bytesBase64` in execute `params`, never inline in `code`. * Requires `confirm: true`; use `dryRun: true` to preview the session plan. * * @param params.path - Destination Dropbox path starting with `/` (prefer `/Apps/Kody/...`). * @param params.bytesBase64 - Full file bytes as base64 (in `params`, not `code`). * @param params.mode - `add` (default) or `overwrite`. * @param params.confirm - Required `true` to perform the mutating call. * @param params.dryRun - When true, return the planned session steps without calling Dropbox. * @returns Dropbox file metadata, or a dry-run session plan. * * @example * import uploadLarge from 'kody:@kentcdodds/dropbox/upload-large' * const result = await uploadLarge({ * path: '/Apps/Kody/notes/report.bin', * bytesBase64: params.bytesBase64, * mode: 'overwrite', * confirm: true, * }) */ import { encodeBase64, decodeBase64 } from './base64.ts' import { DropboxApiError, request } from './request.ts' import { APP_FOLDER_ROOT, UPLOAD_SESSION_CHUNK_BYTES, suggestAppFolderPath, } from './paths.ts' import type { DropboxRequestResult, JsonRecord } from './types.ts' import { optionalBoolean, requireDropboxPath, requireRecord, requireString, } from './types.ts' export type UploadLargeInput = { path: string bytesBase64: string mode?: 'add' | 'overwrite' autorename?: boolean mute?: boolean strictConflict?: boolean /** Override session chunk size in bytes (default 48 KiB). */ chunkBytes?: number confirm?: boolean dryRun?: boolean } /** * Upload base64 bytes via a Dropbox upload session (start → append → finish). * Prefer this (or `./upload`, which auto-delegates above 120 KiB binary) for * larger payloads. Put `bytesBase64` in execute `params`, never inline in `code`. * Requires `confirm: true`; use `dryRun: true` to preview the session plan. * * @param params.path - Destination Dropbox path starting with `/` (prefer `/Apps/Kody/...`). * @param params.bytesBase64 - Full file bytes as base64 (in `params`, not `code`). * @param params.mode - `add` (default) or `overwrite`. * @param params.confirm - Required `true` to perform the mutating call. * @param params.dryRun - When true, return the planned session steps without calling Dropbox. * @returns Dropbox file metadata, or a dry-run session plan. * * @example * import uploadLarge from 'kody:@kentcdodds/dropbox/upload-large' * const result = await uploadLarge({ * path: '/Apps/Kody/notes/report.bin', * bytesBase64: params.bytesBase64, * mode: 'overwrite', * confirm: true, * }) */ export async function uploadLarge( input: UploadLargeInput, ): Promise { const path = requireDropboxPath(input.path) const bytesBase64 = requireString(input.bytesBase64, 'bytesBase64', { allowEmpty: true }) const mode = input.mode ?? 'add' if (mode !== 'add' && mode !== 'overwrite') { throw new Error('mode must be "add" or "overwrite".') } const chunkBytes = input.chunkBytes ?? UPLOAD_SESSION_CHUNK_BYTES if (!Number.isInteger(chunkBytes) || chunkBytes < 1024 || chunkBytes > 8 * 1024 * 1024) { throw new Error('chunkBytes must be an integer from 1024 through 8388608.') } const bytes = decodeBase64(bytesBase64) const commit = { path, mode, autorename: optionalBoolean(input.autorename, 'autorename') ?? false, mute: optionalBoolean(input.mute, 'mute') ?? false, strict_conflict: optionalBoolean(input.strictConflict, 'strictConflict') ?? false, } const chunkCount = bytes.byteLength === 0 ? 1 : Math.ceil(bytes.byteLength / chunkBytes) if (input.dryRun) { return { dryRun: true, strategy: 'upload_session', path, byteLength: bytes.byteLength, chunkBytes, chunkCount, mode, steps: [ 'files/upload_session/start (first chunk or empty)', ...(chunkCount > 2 ? [`files/upload_session/append_v2 × ${chunkCount - 2}`] : []), 'files/upload_session/finish (final chunk + commit; closes session)', ], hint: 'Pass bytesBase64 only in execute params — never inline megabytes in the code string.', authorization: '[managed dropbox OAuth integration]', } } if (input.confirm !== true) { throw new Error( 'upload-large may mutate Dropbox. Pass confirm: true only after explicit user approval, or use dryRun: true.', ) } try { return await runUploadSession({ bytes, chunkBytes, commit }) } catch (error) { throw enhancePathError(error, path) } } async function runUploadSession(options: { bytes: Uint8Array chunkBytes: number commit: JsonRecord }): Promise { const { bytes, chunkBytes, commit } = options if (bytes.byteLength === 0) { const started = await request({ endpoint: 'files/upload_session/start', kind: 'upload', apiArg: { close: false }, bytesBase64: '', confirm: true, }) const sessionId = requireSessionId(started) const finished = await request({ endpoint: 'files/upload_session/finish', kind: 'upload', apiArg: { cursor: { session_id: sessionId, offset: 0 }, commit, }, bytesBase64: '', confirm: true, }) return unwrapBody(finished) } let offset = 0 const firstEnd = Math.min(chunkBytes, bytes.byteLength) const firstChunk = bytes.subarray(0, firstEnd) const started = await request({ endpoint: 'files/upload_session/start', kind: 'upload', apiArg: { close: false }, bytesBase64: encodeBase64(firstChunk), confirm: true, }) const sessionId = requireSessionId(started) offset = firstEnd while (offset < bytes.byteLength) { const remaining = bytes.byteLength - offset const isLast = remaining <= chunkBytes const end = offset + (isLast ? remaining : chunkBytes) const chunk = bytes.subarray(offset, end) if (isLast) { const finished = await request({ endpoint: 'files/upload_session/finish', kind: 'upload', apiArg: { cursor: { session_id: sessionId, offset }, commit, }, bytesBase64: encodeBase64(chunk), confirm: true, }) return unwrapBody(finished) } await request({ endpoint: 'files/upload_session/append_v2', kind: 'upload', apiArg: { cursor: { session_id: sessionId, offset }, close: false, }, bytesBase64: encodeBase64(chunk), confirm: true, }) offset = end } // Exact multiple of chunk size: finish with empty body after last append. const finished = await request({ endpoint: 'files/upload_session/finish', kind: 'upload', apiArg: { cursor: { session_id: sessionId, offset }, commit, }, bytesBase64: '', confirm: true, }) return unwrapBody(finished) } function requireSessionId(result: DropboxRequestResult | JsonRecord): string { const body = unwrapBody(result) const sessionId = body.session_id if (typeof sessionId !== 'string' || !sessionId) { throw new Error('upload_session/start did not return session_id.') } return sessionId } function unwrapBody(result: DropboxRequestResult | JsonRecord): JsonRecord { if (result && typeof result === 'object' && 'body' in result && 'ok' in result) { const body = (result as DropboxRequestResult).body if (!body || typeof body !== 'object' || Array.isArray(body)) { throw new Error('Unexpected Dropbox upload-session response body.') } return body as JsonRecord } return result as JsonRecord } function enhancePathError(error: unknown, path: string): Error { if (!(error instanceof DropboxApiError)) { return error instanceof Error ? error : new Error(String(error)) } const details = JSON.stringify(error.details ?? '') const looksLikePath = error.status === 409 || /path\/not_found|path\/malformed|not_found/i.test(details) || /path\/not_found|path\/malformed|not_found/i.test(error.message) if (!looksLikePath) return error const suggestion = suggestAppFolderPath(path) const hint = suggestion !== path ? ` Path may need the Kody app-folder prefix. Try "${suggestion}" (APP_FOLDER_ROOT is ${APP_FOLDER_ROOT}). List listFolder({ path: '' }) first.` : ` Writable Kody files usually live under ${APP_FOLDER_ROOT}/…. List listFolder({ path: '' }) first.` return new DropboxApiError(`${error.message}.${hint}`, { status: error.status, statusText: error.statusText, requestId: error.requestId, details: error.details, }) } /** * Upload base64 bytes via a Dropbox upload session. * Requires `confirm: true`; use `dryRun: true` to preview. * @example * import uploadLarge from 'kody:@kentcdodds/dropbox/upload-large' * const result = await uploadLarge({ * path: '/Apps/Kody/notes/report.bin', * bytesBase64: params.bytesBase64, * mode: 'overwrite', * confirm: true, * }) */ export default async function uploadLargeEntrypoint( params: Partial & Record = {}, ) { const input = requireRecord(params, 'upload-large') requireDropboxPath(input.path) requireString( typeof input.bytesBase64 === 'string' ? input.bytesBase64 : '', 'bytesBase64', { allowEmpty: true }, ) return uploadLarge({ ...(input as unknown as UploadLargeInput), bytesBase64: typeof input.bytesBase64 === 'string' ? input.bytesBase64 : '', }) }