Skip to content

src/upload-large.ts

292 lines · 9.1 KB · TypeScript
Raw
/**
 * 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<DropboxRequestResult | JsonRecord> {
	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<JsonRecord> {
	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<UploadLargeInput> & Record<string, unknown> = {},
) {
	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 : '',
	})
}