← Public packages
@kentcdodds/dropbox
Upload, share, and manage Dropbox files via OAuth — small or large uploads and direct download URLs.
src/upload-large.ts
292 lines · 9.1 KB · TypeScript/**
* 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 : '',
})
}