← Public packages
@kentcdodds/dropbox
Upload, share, and manage Dropbox files via OAuth — small or large uploads and direct download URLs.
src/upload-from-url.ts
201 lines · 6.9 KB · TypeScript/**
* Save a remote HTTP(S) URL into Dropbox via `files/save_url` (Dropbox fetches the URL).
* Use when MCP chat cannot carry large base64 (phone / MCP-only). Requires `confirm: true`.
* Polls `files/save_url/check_job_status` when Dropbox returns an async job.
*
* @param params.path - Destination Dropbox path starting with `/` (prefer `/Apps/Kody/...`).
* @param params.url - Public HTTP(S) URL Dropbox can fetch.
* @param params.confirm - Required `true` to perform the mutating call.
* @param params.dryRun - When true, return the planned request without calling Dropbox.
* @param params.pollMs - Poll interval for async jobs (default 1500).
* @param params.maxPolls - Max poll attempts (default 40).
* @returns Dropbox file metadata (or dry-run / async failure details).
*
* @example
* import uploadFromUrl from 'kody:@kentcdodds/dropbox/upload-from-url'
* const result = await uploadFromUrl({
* path: '/Apps/Kody/inbox/photo.webp',
* url: 'https://example.com/photo.webp',
* confirm: true,
* })
*/
import { DropboxApiError, dropboxRpc, request } from './request.ts'
import { APP_FOLDER_ROOT, suggestAppFolderPath } from './paths.ts'
import type { DropboxRequestResult, JsonRecord } from './types.ts'
import {
optionalNumber,
requireDropboxPath,
requireRecord,
requireString,
} from './types.ts'
export type UploadFromUrlInput = {
path: string
url: string
confirm?: boolean
dryRun?: boolean
pollMs?: number
maxPolls?: number
}
/**
* Save a remote HTTP(S) URL into Dropbox via `files/save_url` (Dropbox fetches the URL).
* Use when MCP chat cannot carry large base64 (phone / MCP-only). Requires `confirm: true`.
* Polls `files/save_url/check_job_status` when Dropbox returns an async job.
*
* @param params.path - Destination Dropbox path starting with `/` (prefer `/Apps/Kody/...`).
* @param params.url - Public HTTP(S) URL Dropbox can fetch.
* @param params.confirm - Required `true` to perform the mutating call.
* @param params.dryRun - When true, return the planned request without calling Dropbox.
* @param params.pollMs - Poll interval for async jobs (default 1500).
* @param params.maxPolls - Max poll attempts (default 40).
* @returns Dropbox file metadata (or dry-run / async failure details).
*
* @example
* import uploadFromUrl from 'kody:@kentcdodds/dropbox/upload-from-url'
* const result = await uploadFromUrl({
* path: '/Apps/Kody/inbox/photo.webp',
* url: 'https://example.com/photo.webp',
* confirm: true,
* })
*/
export async function uploadFromUrl(
input: UploadFromUrlInput,
): Promise<DropboxRequestResult | JsonRecord> {
const path = requireDropboxPath(input.path)
const url = requireString(input.url, 'url')
if (!/^https?:\/\//i.test(url)) {
throw new Error('url must be an http(s) URL Dropbox can fetch.')
}
const pollMs = optionalNumber(input.pollMs, 'pollMs') ?? 1500
const maxPolls = optionalNumber(input.maxPolls, 'maxPolls') ?? 40
if (!Number.isInteger(pollMs) || pollMs < 200 || pollMs > 30_000) {
throw new Error('pollMs must be an integer from 200 through 30000.')
}
if (!Number.isInteger(maxPolls) || maxPolls < 1 || maxPolls > 120) {
throw new Error('maxPolls must be an integer from 1 through 120.')
}
if (input.dryRun) {
return {
dryRun: true,
endpoint: 'files/save_url',
path,
url,
poll: 'files/save_url/check_job_status when async_job_id is returned',
authorization: '[managed dropbox OAuth integration]',
}
}
if (input.confirm !== true) {
throw new Error(
'upload-from-url may mutate Dropbox. Pass confirm: true only after explicit user approval, or use dryRun: true.',
)
}
try {
const started = await request({
endpoint: 'files/save_url',
body: { path, url },
confirm: true,
})
const body = unwrapBody(started)
const tag = body['.tag']
if (tag === 'complete') {
const { ['.tag']: _tag, ...metadata } = body
return metadata
}
if (typeof body.async_job_id === 'string' && body.async_job_id) {
return await pollSaveUrlJob(body.async_job_id, pollMs, maxPolls)
}
if (tag === 'async_job_id' && typeof body.async_job_id === 'string') {
return await pollSaveUrlJob(body.async_job_id, pollMs, maxPolls)
}
return body
} catch (error) {
throw enhancePathError(error, path)
}
}
async function pollSaveUrlJob(
asyncJobId: string,
pollMs: number,
maxPolls: number,
): Promise<JsonRecord> {
for (let attempt = 0; attempt < maxPolls; attempt += 1) {
const status = await dropboxRpc<JsonRecord>('files/save_url/check_job_status', {
async_job_id: asyncJobId,
})
const tag = status['.tag']
if (tag === 'complete') {
const { ['.tag']: _tag, ...metadata } = status
return { ...metadata, async_job_id: asyncJobId }
}
if (tag === 'failed') {
throw new Error(`files/save_url job failed: ${JSON.stringify(status)}`)
}
await sleep(pollMs)
}
throw new Error(
`files/save_url job ${asyncJobId} still in progress after ${maxPolls} polls. Check later with files/save_url/check_job_status.`,
)
}
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 files/save_url response body.')
}
return body as JsonRecord
}
return result as JsonRecord
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
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}).`
: ` Writable Kody files usually live under ${APP_FOLDER_ROOT}/….`
return new DropboxApiError(`${error.message}.${hint}`, {
status: error.status,
statusText: error.statusText,
requestId: error.requestId,
details: error.details,
})
}
/**
* Save a remote URL into Dropbox via files/save_url.
* Requires `confirm: true`; use `dryRun: true` to preview.
* @example
* import uploadFromUrl from 'kody:@kentcdodds/dropbox/upload-from-url'
* const result = await uploadFromUrl({
* path: '/Apps/Kody/inbox/photo.webp',
* url: 'https://example.com/photo.webp',
* confirm: true,
* })
*/
export default async function uploadFromUrlEntrypoint(
params: Partial<UploadFromUrlInput> & Record<string, unknown> = {},
) {
const input = requireRecord(params, 'upload-from-url')
requireDropboxPath(input.path)
requireString(input.url, 'url')
return uploadFromUrl(input as unknown as UploadFromUrlInput)
}