[SDK] Add refreshSession and migrate the session-pinned infra (#11381)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-13 22:26:20 +03:00
committed by GitHub
parent 0c93d1e416
commit a4f2811f39
32 changed files with 676 additions and 268 deletions
+4 -2
View File
@@ -1,3 +1,5 @@
import {type app} from '#/lexicons'
/**
* One part of a multipart upload. `partNumber` is 1-indexed to match the S3
* convention the backend uses.
@@ -57,13 +59,13 @@ export type UploadStatusResponse = {
expiresAt: string
state: UploadState
completedJobId?: string
jobStatus?: import('@atproto/api').AppBskyVideoDefs.JobStatus
jobStatus?: app.bsky.video.defs.JobStatus
failureReason?: string
}
export type FinishUploadResponse = {
completedJobId: string
jobStatus: import('@atproto/api').AppBskyVideoDefs.JobStatus
jobStatus: app.bsky.video.defs.JobStatus
}
export type AbortUploadResponse = Pick<
+21 -11
View File
@@ -1,9 +1,10 @@
import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
import {type Client} from '@atproto/lex'
import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {type CompressedVideo} from '#/lib/media/video/types'
import {shouldRetryError} from '#/lib/strings/errors'
import {type app} from '#/lexicons'
import {getServiceAuthToken} from '../upload.shared'
import {mimeToExt} from '../util'
import {
@@ -29,19 +30,22 @@ export class MultipartFallbackError extends Error {}
export async function uploadVideoMultipart({
video,
agent,
client,
dispatchUrl,
setProgress,
signal,
onStarted,
}: {
video: CompressedVideo
agent: AtpAgent
client: Client
/** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
dispatchUrl: string | URL
setProgress: (progress: number) => void
signal: AbortSignal
onStarted?: () => void
}): Promise<AppBskyVideoDefs.JobStatus> {
}): Promise<app.bsky.video.defs.JobStatus> {
throwIfAborted(signal)
const tokenProvider = createTokenProvider(agent, signal)
const tokenProvider = createTokenProvider(client, dispatchUrl, signal)
const token = await tokenProvider.get()
const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}`
let session
@@ -134,7 +138,7 @@ async function finishAndRecover({
getToken: (forceRefresh?: boolean) => Promise<string>
signal: AbortSignal
resendMissingParts: (receivedPartNumbers: number[]) => Promise<boolean>
}): Promise<AppBskyVideoDefs.JobStatus> {
}): Promise<app.bsky.video.defs.JobStatus> {
let createdFailures = 0
let forceTokenRefresh = true
while (true) {
@@ -224,7 +228,7 @@ async function abortThenFallbackOrResolve(
jobId: string,
token: string,
cause: unknown,
): Promise<AppBskyVideoDefs.JobStatus> {
): Promise<app.bsky.video.defs.JobStatus> {
const result = await abortUploadWithRetry(jobId, token)
if (result.state === 'aborted') {
throw new MultipartFallbackError(
@@ -264,7 +268,11 @@ async function abortUploadWithRetry(jobId: string, token: string) {
throw lastError
}
function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
function createTokenProvider(
client: Client,
dispatchUrl: string | URL,
signal: AbortSignal,
) {
let token: string | undefined
let expiresAt = 0
let refresh: Promise<string> | undefined
@@ -273,7 +281,7 @@ function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
if (!forceRefresh && token && Date.now() < expiresAt - 60_000) return token
if (!refresh) {
const exp = Math.floor(Date.now() / 1000) + 60 * 30
refresh = getServiceAuthTokenWithRetry(agent, exp, signal)
refresh = getServiceAuthTokenWithRetry(client, dispatchUrl, exp, signal)
.then(nextToken => {
token = nextToken
expiresAt = exp * 1000
@@ -290,7 +298,8 @@ function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
}
async function getServiceAuthTokenWithRetry(
agent: AtpAgent,
client: Client,
dispatchUrl: string | URL,
exp: number,
signal: AbortSignal,
) {
@@ -299,7 +308,8 @@ async function getServiceAuthTokenWithRetry(
throwIfAborted(signal)
try {
return await getServiceAuthToken({
agent,
client,
dispatchUrl,
lxm: 'com.atproto.repo.uploadBlob',
exp,
})
+34 -16
View File
@@ -1,44 +1,62 @@
import {type AtpAgent} from '@atproto/api'
import {type Client} from '@atproto/lex'
import {type DidString, type NsidString} from '@atproto/syntax'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {VIDEO_SERVICE_DID} from '#/lib/constants'
import {UploadLimitError} from '#/lib/media/video/errors'
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
import {createVideoAgent} from './util'
import {app, com} from '#/lexicons'
import {createVideoServiceClient} from './util'
export async function getServiceAuthToken({
agent,
client,
dispatchUrl,
aud,
lxm,
exp,
}: {
agent: AtpAgent
client: Client
/**
* The account's dispatch URL (the old `agent.dispatchUrl`: its PDS, falling
* back to the account service). Only needed when `aud` is omitted, so the
* default audience can be derived from the PDS host. A lex {@link Client} does
* not expose this - it resolves the PDS per request internally - so the caller,
* which holds the session, passes it in.
*/
dispatchUrl?: string | URL
aud?: string
lxm: string
lxm: NsidString
exp?: number
}) {
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!pdsAud) {
throw new Error('Agent does not have a PDS URL')
let resolvedAud = aud
if (!resolvedAud) {
if (!dispatchUrl) {
throw new Error('Missing service auth audience: no aud or dispatchUrl')
}
const pdsAud = getServiceAuthAudFromUrl(dispatchUrl)
if (!pdsAud) {
throw new Error('Agent does not have a PDS URL')
}
resolvedAud = pdsAud
}
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
aud: aud ?? pdsAud,
const {token} = await client.call(com.atproto.server.getServiceAuth, {
aud: resolvedAud as DidString,
lxm,
exp,
})
return serviceAuth.token
return token
}
export async function getVideoUploadLimits(agent: AtpAgent, i18n: I18n) {
export async function getVideoUploadLimits(client: Client, i18n: I18n) {
const token = await getServiceAuthToken({
agent,
client,
lxm: 'app.bsky.video.getUploadLimits',
aud: VIDEO_SERVICE_DID,
})
const videoAgent = createVideoAgent()
const {data: limits} = await videoAgent.app.bsky.video
.getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}})
const videoClient = createVideoServiceClient(token)
const limits = await videoClient
.call(app.bsky.video.getUploadLimits)
.catch(err => {
if (err instanceof Error) {
throw new UploadLimitError(err.message)
+13 -7
View File
@@ -1,5 +1,5 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system/legacy'
import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
import {type Client} from '@atproto/lex'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {nanoid} from 'nanoid/non-secure'
@@ -11,13 +11,15 @@ import {
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {Features, features} from '#/analytics/features'
import {type app} from '#/lexicons'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({
video,
agent,
client,
dispatchUrl,
did,
setProgress,
signal,
@@ -25,7 +27,9 @@ export async function uploadVideo({
onTransport,
}: {
video: CompressedVideo
agent: AtpAgent
client: Client
/** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
dispatchUrl: string | URL
did: string
setProgress: (progress: number) => void
signal: AbortSignal
@@ -35,13 +39,14 @@ export async function uploadVideo({
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, i18n)
await getVideoUploadLimits(client, i18n)
if (features.isOn(Features.VideoMultipartUploadEnable)) {
try {
return await uploadVideoMultipart({
video,
agent,
client,
dispatchUrl,
setProgress,
signal,
onStarted: () => onTransport?.('multipart'),
@@ -64,7 +69,8 @@ export async function uploadVideo({
throw new AbortError()
}
const token = await getServiceAuthToken({
agent,
client,
dispatchUrl,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
@@ -91,7 +97,7 @@ export async function uploadVideo({
throw new Error('No response')
}
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
const responseBody = JSON.parse(res.body) as app.bsky.video.defs.JobStatus
if (!responseBody.jobId) {
throw new ServerError(
+14 -8
View File
@@ -1,4 +1,4 @@
import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
import {type Client} from '@atproto/lex'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {nanoid} from 'nanoid/non-secure'
@@ -10,13 +10,15 @@ import {
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {Features, features} from '#/analytics/features'
import {type app} from '#/lexicons'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({
video,
agent,
client,
dispatchUrl,
did,
setProgress,
signal,
@@ -24,7 +26,9 @@ export async function uploadVideo({
onTransport,
}: {
video: CompressedVideo
agent: AtpAgent
client: Client
/** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
dispatchUrl: string | URL
did: string
setProgress: (progress: number) => void
signal: AbortSignal
@@ -34,13 +38,14 @@ export async function uploadVideo({
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, i18n)
await getVideoUploadLimits(client, i18n)
if (features.isOn(Features.VideoMultipartUploadEnable)) {
try {
return await uploadVideoMultipart({
video,
agent,
client,
dispatchUrl,
setProgress,
signal,
onStarted: () => onTransport?.('multipart'),
@@ -71,7 +76,8 @@ export async function uploadVideo({
throw new AbortError()
}
const token = await getServiceAuthToken({
agent,
client,
dispatchUrl,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
@@ -80,7 +86,7 @@ export async function uploadVideo({
throw new AbortError()
}
const xhr = new XMLHttpRequest()
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
const res = await new Promise<app.bsky.video.defs.JobStatus>(
(resolve, reject) => {
xhr.upload.addEventListener('progress', e => {
const progress = e.loaded / e.total
@@ -92,7 +98,7 @@ export async function uploadVideo({
} else if (xhr.readyState === 4) {
const uploadRes = JSON.parse(
xhr.responseText,
) as AppBskyVideoDefs.JobStatus
) as app.bsky.video.defs.JobStatus
resolve(uploadRes)
} else {
reject(new ServerError(i18n._(msg`Failed to upload video`)))
+20 -4
View File
@@ -1,6 +1,5 @@
import {AtpAgent} from '@atproto/api'
import {type SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
export const createVideoEndpointUrl = (
route: string,
@@ -16,12 +15,29 @@ export const createVideoEndpointUrl = (
return url.href
}
export function createVideoAgent() {
return new AtpAgent({
/**
* A non-refreshing single-use lex {@link Client} scoped to the video service and
* authenticated by a per-call service-auth token. It has no session, so nothing
* can refresh it: requests go straight to the video service with the token as a
* static `authorization` header, which a raw client - unlike a session-backed
* one - is allowed to preset. Mirrors the scoped client in
* `#/ageAssurance/useBeginAgeAssurance`.
*/
export function createVideoServiceClient(token: string) {
return createLexClient({
service: VIDEO_SERVICE,
headers: {authorization: `Bearer ${token}`},
})
}
/**
* An unauthenticated lex {@link Client} scoped to the video service, for public
* reads like `getJobStatus` polling.
*/
export function createTokenlessVideoServiceClient() {
return createLexClient({service: VIDEO_SERVICE})
}
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
switch (mimeType) {
case 'video/mp4':