← Public packages
@kentcdodds/x
X API v2 helpers for tweets, search, legacy DMs, and encrypted X Chat via a Fly XDK sidecar.
src/upload-media.ts
436 lines · 13.3 KB · TypeScriptimport { createAuthenticatedFetch } from 'kody:runtime'
import {
ValidationError,
boolean,
object,
optional,
parse,
string,
} from 'remix/data-schema'
import { resolveOAuthAccount } from './accounts.ts'
import { extractRateLimit, oauthTokenStorageNotes } from './domain.ts'
import type { JsonRecord, XAccountParams, XDryRunResult } from './types.ts'
const API_BASE = 'https://api.x.com/2'
/** ~4 MiB chunks; X server max is 8 MiB per append segment. */
const CHUNK_BYTES = 4 * 1024 * 1024
const MAX_STATUS_POLLS = 60
const nonEmptyString = string().refine((value) => value.trim().length > 0, 'Expected non-empty string')
export type XUploadMediaParams = XAccountParams & {
/** Public HTTPS URL of the media bytes. Mutually exclusive with bytesBase64. */
url?: string
/** Base64-encoded media bytes (raw or data-URL). Mutually exclusive with url. */
bytesBase64?: string
/** MIME type, e.g. image/jpeg or video/mp4. */
mediaType: string
/** X media_category; defaults from mediaType (tweet_image / tweet_gif / tweet_video). */
mediaCategory?: string
dryRun?: boolean
/** Required true to actually upload. */
confirm?: boolean
}
export type XUploadMediaResult = {
mediaId: string
mediaKey?: string
state: string
mediaCategory: string
mediaType: string
totalBytes: number
account: string | null
integration: string
}
const UploadMediaInputSchema = object(
{
url: optional(nonEmptyString),
bytesBase64: optional(nonEmptyString),
mediaType: nonEmptyString,
mediaCategory: optional(nonEmptyString),
account: optional(string()),
integration: optional(string()),
dryRun: optional(boolean()),
confirm: optional(boolean()),
},
{ unknownKeys: 'error' },
).refine((raw) => {
const hasUrl = typeof raw.url === 'string' && raw.url.trim().length > 0
const hasB64 = typeof raw.bytesBase64 === 'string' && raw.bytesBase64.trim().length > 0
return hasUrl !== hasB64
}, 'Provide exactly one of url or bytesBase64')
function formatValidationError(error: ValidationError): Error {
const details = (error.issues ?? [])
.map((issue) => {
const path = Array.isArray(issue.path) && issue.path.length ? issue.path.join('.') : '(root)'
return `${path}: ${issue.message}`
})
.join('; ')
return new Error(
`upload-media input invalid: ${details || error.message}. ` +
`Params: { account?, url | bytesBase64, mediaType, mediaCategory?, dryRun?, confirm? }. Unknown keys are rejected.`,
)
}
function parseUploadMediaInput(input: unknown): XUploadMediaParams {
try {
return parse(UploadMediaInputSchema, input ?? {}) as XUploadMediaParams
} catch (error) {
if (error instanceof ValidationError) throw formatValidationError(error)
throw error
}
}
export function defaultMediaCategory(mediaType: string): string {
const t = mediaType.trim().toLowerCase()
if (t === 'image/gif') return 'tweet_gif'
if (t.startsWith('video/')) return 'tweet_video'
return 'tweet_image'
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function decodeBase64Media(bytesBase64: string): Uint8Array {
const raw = bytesBase64.trim()
const comma = raw.indexOf(',')
const b64 = raw.startsWith('data:') && comma >= 0 ? raw.slice(comma + 1) : raw
const binary = atob(b64)
const out = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
return out
}
async function resolveMediaBytes(params: {
url?: string
bytesBase64?: string
}): Promise<{ bytes: Uint8Array; totalBytes: number; source: 'url' | 'bytesBase64' }> {
if (params.bytesBase64) {
const bytes = decodeBase64Media(params.bytesBase64)
return { bytes, totalBytes: bytes.byteLength, source: 'bytesBase64' }
}
if (!params.url) throw new Error('url or bytesBase64 is required')
const response = await fetch(params.url)
if (!response.ok) {
throw new Error(`Failed to fetch media url: ${response.status} ${response.statusText}`)
}
const buffer = new Uint8Array(await response.arrayBuffer())
return { bytes: buffer, totalBytes: buffer.byteLength, source: 'url' }
}
/** Size only — prefer HEAD for url dry-runs so large bodies stay out of memory. */
async function resolveMediaSize(params: {
url?: string
bytesBase64?: string
}): Promise<{ totalBytes: number; source: 'url' | 'bytesBase64'; sizeVia: 'head' | 'base64' | 'get' }> {
if (params.bytesBase64) {
return {
totalBytes: decodeBase64Media(params.bytesBase64).byteLength,
source: 'bytesBase64',
sizeVia: 'base64',
}
}
if (!params.url) throw new Error('url or bytesBase64 is required')
try {
const head = await fetch(params.url, { method: 'HEAD' })
const len = head.headers.get('content-length')
if (head.ok && len && /^\d+$/.test(len)) {
return { totalBytes: Number(len), source: 'url', sizeVia: 'head' }
}
} catch {
// fall through
}
const response = await fetch(params.url)
if (!response.ok) {
throw new Error(`Failed to probe media url: ${response.status} ${response.statusText}`)
}
const len = response.headers.get('content-length')
if (len && /^\d+$/.test(len)) {
try {
await response.body?.cancel()
} catch {
/* ignore */
}
return { totalBytes: Number(len), source: 'url', sizeVia: 'head' }
}
const buffer = new Uint8Array(await response.arrayBuffer())
return { totalBytes: buffer.byteLength, source: 'url', sizeVia: 'get' }
}
class MediaUploadError extends Error {
readonly status: number
readonly details: unknown
constructor(message: string, status: number, details: unknown) {
super(message)
this.name = 'MediaUploadError'
this.status = status
this.details = details
}
}
async function parseJson(response: Response): Promise<unknown> {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
async function oauthFetch(
integrationName: string,
url: string,
init: RequestInit,
): Promise<Response> {
const authenticatedFetch = await Promise.resolve(createAuthenticatedFetch(integrationName))
return authenticatedFetch(url, init)
}
async function oauthJson(
integrationName: string,
url: string,
init: RequestInit,
): Promise<{ body: unknown; response: Response }> {
const response = await oauthFetch(integrationName, url, init)
const body = await parseJson(response)
if (!response.ok) {
const record = body && typeof body === 'object' ? (body as JsonRecord) : null
const message =
(typeof record?.detail === 'string' && record.detail) ||
(typeof record?.title === 'string' && record.title) ||
`X media upload failed: ${response.status} ${response.statusText}`
throw new MediaUploadError(message, response.status, {
body,
rateLimit: extractRateLimit(response),
url,
})
}
return { body, response }
}
function asRecord(value: unknown): JsonRecord | null {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : null
}
function mediaData(body: unknown): JsonRecord {
const root = asRecord(body) ?? {}
const data = asRecord(root.data)
return data ?? root
}
function readMediaKey(data: JsonRecord): string | undefined {
if (typeof data.media_key === 'string') return data.media_key
if (typeof data.mediaKey === 'string') return data.mediaKey
return undefined
}
/**
* Upload media via X API v2 chunked upload on api.x.com (not upload.x.com).
*
* Flow: initialize → append (~4 MiB segments) → finalize → poll STATUS until succeeded.
* Dry-run by default; pass `confirm: true` to upload. Returns `{ mediaId, mediaKey, state }`
* for `create-post` `media.media_ids`.
*
* Requires OAuth scope `media.write` on the account integration.
*
* @example
* import uploadMedia from 'kody:@kentcdodds/x/upload-media'
* const preview = await uploadMedia({
* account: 'kodykoala',
* url: 'https://example.com/tiny.jpg',
* mediaType: 'image/jpeg',
* dryRun: true,
* })
* // => { dryRun: true, action: 'upload-media', requiresConfirm: true, ... }
*/
export default async function uploadMediaEntrypoint(
params: XUploadMediaParams | Record<string, unknown> = {},
): Promise<XUploadMediaResult | (XDryRunResult & JsonRecord)> {
const input = parseUploadMediaInput(params)
const mediaType = input.mediaType.trim()
const mediaCategory = (input.mediaCategory?.trim() || defaultMediaCategory(mediaType)).trim()
const oauth = await resolveOAuthAccount({
account: input.account,
integration: input.integration,
})
const size = await resolveMediaSize({
url: input.url,
bytesBase64: input.bytesBase64,
})
if (input.dryRun || !input.confirm) {
const segmentCount = Math.max(1, Math.ceil(size.totalBytes / CHUNK_BYTES) || 1)
return {
dryRun: true,
action: 'upload-media',
requiresConfirm: true,
host: 'api.x.com',
steps: [
{
method: 'POST',
url: `${API_BASE}/media/upload/initialize`,
body: {
media_type: mediaType,
total_bytes: size.totalBytes,
media_category: mediaCategory,
},
},
{
method: 'POST',
url: `${API_BASE}/media/upload/{id}/append`,
note: `multipart segment_index + media; ~${CHUNK_BYTES} byte chunks; ${segmentCount} segment(s)`,
},
{
method: 'POST',
url: `${API_BASE}/media/upload/{id}/finalize`,
},
{
method: 'GET',
url: `${API_BASE}/media/upload?command=STATUS&media_id={id}`,
note: 'Poll until processing_info.state is succeeded (pending/in_progress + check_after_secs)',
},
],
payload: {
mediaType,
mediaCategory,
totalBytes: size.totalBytes,
sizeVia: size.sizeVia,
source: size.source,
url: input.url,
bytesBase64Provided: Boolean(input.bytesBase64),
chunkBytes: CHUNK_BYTES,
segmentCount,
account: oauth.account,
integration: oauth.integrationName,
createPostShape: {
text: 'Post text',
media: { media_ids: ['<mediaId from confirm:true>'] },
account: oauth.account ?? undefined,
dryRun: true,
},
},
oauthNotes: oauthTokenStorageNotes(oauth),
}
}
if (size.totalBytes <= 0) {
throw new Error('upload-media: media is empty (0 bytes)')
}
const { bytes } = await resolveMediaBytes({
url: input.url,
bytesBase64: input.bytesBase64,
})
const totalBytes = bytes.byteLength
if (totalBytes <= 0) {
throw new Error('upload-media: media is empty (0 bytes)')
}
const init = await oauthJson(oauth.integrationName, `${API_BASE}/media/upload/initialize`, {
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({
media_type: mediaType,
total_bytes: totalBytes,
media_category: mediaCategory,
}),
})
const initData = mediaData(init.body)
const mediaId = String(initData.id ?? initData.media_id ?? '')
if (!mediaId) {
throw new Error('upload-media: initialize did not return media id')
}
let mediaKey = readMediaKey(initData)
let segmentIndex = 0
for (let offset = 0; offset < totalBytes; offset += CHUNK_BYTES) {
const chunk = bytes.subarray(offset, Math.min(offset + CHUNK_BYTES, totalBytes))
const form = new FormData()
form.append('segment_index', String(segmentIndex))
form.append('media', new Blob([chunk], { type: mediaType }), `chunk-${segmentIndex}`)
const appendResponse = await oauthFetch(
oauth.integrationName,
`${API_BASE}/media/upload/${encodeURIComponent(mediaId)}/append`,
{ method: 'POST', headers: { accept: 'application/json' }, body: form },
)
if (!appendResponse.ok) {
const body = await parseJson(appendResponse)
throw new MediaUploadError(
`upload-media append segment ${segmentIndex} failed: ${appendResponse.status}`,
appendResponse.status,
{ body, rateLimit: extractRateLimit(appendResponse) },
)
}
segmentIndex += 1
}
const finalized = await oauthJson(
oauth.integrationName,
`${API_BASE}/media/upload/${encodeURIComponent(mediaId)}/finalize`,
{
method: 'POST',
headers: { accept: 'application/json' },
},
)
const finalizedData = mediaData(finalized.body)
mediaKey = readMediaKey(finalizedData) ?? mediaKey
let state = 'succeeded'
let checkAfterSecs = 1
const processing = asRecord(finalizedData.processing_info)
if (processing && typeof processing.state === 'string') {
state = processing.state
if (typeof processing.check_after_secs === 'number' && processing.check_after_secs > 0) {
checkAfterSecs = processing.check_after_secs
}
}
let polls = 0
while ((state === 'pending' || state === 'in_progress') && polls < MAX_STATUS_POLLS) {
await sleep(Math.min(Math.max(checkAfterSecs, 1), 30) * 1000)
const status = await oauthJson(
oauth.integrationName,
`${API_BASE}/media/upload?command=STATUS&media_id=${encodeURIComponent(mediaId)}`,
{ method: 'GET', headers: { accept: 'application/json' } },
)
const statusData = mediaData(status.body)
mediaKey = readMediaKey(statusData) ?? mediaKey
const info = asRecord(statusData.processing_info)
if (!info || typeof info.state !== 'string') {
state = 'succeeded'
break
}
state = info.state
if (typeof info.check_after_secs === 'number' && info.check_after_secs > 0) {
checkAfterSecs = info.check_after_secs
}
if (state === 'failed') {
throw new MediaUploadError('upload-media: processing failed', 422, {
processing_info: info,
mediaId,
})
}
polls += 1
}
if (state === 'pending' || state === 'in_progress') {
throw new Error(
`upload-media: processing still ${state} after ${MAX_STATUS_POLLS} polls (mediaId=${mediaId})`,
)
}
return {
mediaId,
mediaKey,
state,
mediaCategory,
mediaType,
totalBytes,
account: oauth.account,
integration: oauth.integrationName,
}
}