← Files Velox TriFit API DocsARCHIVED FILE

sdks/javascript/index.mjs

10.9 KB · Nov 7, 2026 · 13:08 UTC

↓ Download file

export class VeloxError extends Error {
  constructor(message, { status = 0, code = 'network_error', requestId, retryAfter } = {}) {
    super(message);
    this.name = 'VeloxError';
    Object.assign(this, { status, code, requestId, retryAfter });
  }
}

export class VeloxDressMe {
  constructor({ baseUrl, clientId, fetch: fetchImpl = globalThis.fetch, timeoutMs = 30000, autoRefresh = false, serverToken, integrationKey, subject }) {
    const url = new URL(baseUrl);
    if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) {
      throw new Error('Use HTTPS or HTTP loopback for the local sandbox');
    }
    if (url.username || url.password || url.search || url.hash) throw new Error('Invalid API base URL');
    if (!clientId || !Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('clientId and positive timeoutMs required');
    this.baseUrl = baseUrl.replace(/\/$/, '');
    this.clientId = clientId;
    // Browser fetch requires the Window/global receiver, not this SDK instance.
    this.fetch = fetchImpl.bind(globalThis);
    this.timeoutMs = timeoutMs;
    this.session = null;
    this.autoRefresh = autoRefresh;
    this.refreshInFlight = null;
    this.serverToken = serverToken;
    if(integrationKey&&typeof window!=='undefined')throw new Error('Integration access keys belong on your backend, never in browser code');
    this.integrationKey=integrationKey;
    if(subject!==undefined&&(!integrationKey?.startsWith('va_')||!/^[A-Za-z0-9_-]{1,128}$/.test(subject)))throw new Error('Subject requires an application credential and an opaque reference');
    this.subject=subject;
  }

  async request(path, { method = 'GET', body, signal, headers = {}, _authRetry = false } = {}) {
    const sentToken = this.session?.accessToken;
    const timeout = AbortSignal.timeout(this.timeoutMs);
    const response = await this.fetch(`${this.baseUrl}${path}`, {
      method, signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
      redirect: 'error',
      headers: {
        'X-Velox-Client-Id': this.clientId,
        ...(this.integrationKey?{'X-Velox-Integration-Key':this.integrationKey}:{}),
        ...(this.subject?{'X-Velox-Subject':this.subject}:{}),
        ...(this.session || this.serverToken ? { Authorization: `Bearer ${this.session?.accessToken || this.serverToken}` } : {}),
        ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...headers
      },
      body: body === undefined ? undefined : JSON.stringify(body)
    });
    const data = response.status === 204 ? null : await response.json();
    if (this.autoRefresh && !_authRetry && !path.startsWith('/auth/') && response.status === 401 && data?.error?.code === 'unauthorized' && this.session) {
      if (this.session.accessToken === sentToken) await this.refresh();
      return this.request(path, { method, body, signal, headers, _authRetry: true });
    }
    if (!response.ok) throw new VeloxError(data?.error?.message || 'Request failed', {
      status: response.status, code: data?.error?.code || 'api_error',
      requestId: response.headers.get('x-request-id'), retryAfter: response.headers.get('retry-after')
    });
    return data;
  }

  requireBackend() { if (typeof window !== 'undefined' || !this.integrationKey) throw new Error('Linked account operations require your authenticated backend and integration key'); }
  createLinkChallenge(externalUserId, purpose = 'link') { this.requireBackend(); return this.request('/auth/linked/challenge', {method:'POST',body:{externalUserId,purpose}}); }
  async linkAccount({challengeId,idToken,consent}) { this.requireBackend(); this.session=await this.request('/auth/linked/link',{method:'POST',body:{challengeId,idToken,consent}}); return this.session; }
  async loginLinkedAccount({challengeId,idToken}) { this.requireBackend(); this.session=await this.request('/auth/linked/session',{method:'POST',body:{challengeId,idToken}}); return this.session; }
  linkedAccount() { this.requireBackend(); return this.request('/auth/linked/account'); }
  async unlinkAccount(linkId) { this.requireBackend(); const result=await this.request('/auth/linked/unlink',{method:'POST',body:{linkId}}); this.session=null; return result; }

  requestCode(email) { return this.request('/auth/email/request-code', { method: 'POST', body: { email } }); }
  async verifyCode(challengeId, code) {
    this.session = await this.request('/auth/email/verify', { method: 'POST', body: { challengeId, code } });
    return this.session;
  }
  async refresh() {
    if (!this.session) throw new Error('No session');
    if (this.refreshInFlight) return this.refreshInFlight;
    const before = this.session;
    this.refreshInFlight = (async () => {
      const updated = await this.request('/auth/refresh', { method: 'POST', body: { refreshToken: before.refreshToken } });
      if (this.session !== before) throw new Error('Session changed during renewal; sign in again');
      this.session = updated;
      return updated;
    })();
    try { return await this.refreshInFlight; }
    finally { this.refreshInFlight = null; }
  }
  async logout() {
    if (!this.session) return;
    await this.request('/auth/logout', { method: 'POST', body: { refreshToken: this.session.refreshToken } });
    this.session = null;
  }
  me() { return this.request('/me'); }
  applicationInfo() { this.requireBackend(); return this.request('/me'); }
  notificationEndpoints() { this.requireBackend(); return this.request('/notification-endpoints'); }
  registerNotificationEndpoint(url) { this.requireBackend(); return this.request('/notification-endpoints',{method:'POST',body:{url}}); }
  setNotificationEndpointState(id,{enabled,expectedRevision}) { this.requireBackend(); return this.request(`/notification-endpoints/${encodeURIComponent(id)}/state`,{method:'POST',body:{enabled,expectedRevision}}); }
  importProductImage(url,{signal}={}) { this.requireBackend(); return this.request('/assets/import',{method:'POST',body:{url},signal}); }
  deleteAsset(id) { this.requireBackend(); return this.request(`/assets/${encodeURIComponent(id)}/delete`,{method:'POST',body:{}}); }
  deleteSubject() { this.requireBackend(); return this.request('/subjects/delete',{method:'POST',body:{}}); }
  profiles() { this.requireBackend(); return this.request('/profiles'); }
  overview({runId}={}) { this.requireBackend(); return this.request('/admin/overview'+(runId?'?runId='+encodeURIComponent(runId):'')); }
  getAsset(id) { return this.request(`/assets/${encodeURIComponent(id)}`); }
  createImageUpload({ role, fileName, contentType, sizeBytes }, { signal } = {}) {
    return this.request('/assets/upload-url', { method: 'POST', signal, body: { role, fileName, contentType, sizeBytes } });
  }
  completeImageUpload(assetId, { signal } = {}) {
    return this.request(`/assets/${encodeURIComponent(assetId)}/complete`, { method: 'POST', signal, body: {} });
  }

  async uploadImage(file, { role, fileName = 'image.png', signal, onAssetCreated } = {}) {
    const target = await this.createImageUpload({
      role, fileName, contentType: file.type, sizeBytes: file.size
    }, { signal });
    // Persist the ID before transferring bytes, without persisting the signed
    // capability. A client can reconcile /complete after an uncertain response.
    if(onAssetCreated)await onAssetCreated(target.assetId);
    // Signed upload URLs are capabilities. Never forward the user's bearer token.
    const uploadUrl = new URL(target.uploadUrl);
    const localApi = new URL(this.baseUrl);
    if (uploadUrl.protocol !== 'https:' && !(localApi.protocol === 'http:' && uploadUrl.origin === localApi.origin)) {
      throw new Error('Unsafe upload URL');
    }
    const timeout = AbortSignal.timeout(this.timeoutMs);
    const response = await this.fetch(uploadUrl, {
      method: 'PUT', headers: target.uploadHeaders, body: file, redirect: 'error',
      signal: signal ? AbortSignal.any([signal, timeout]) : timeout
    });
    if (!response.ok) throw new VeloxError('Image upload failed', { status: response.status, code: 'upload_failed' });
    return this.completeImageUpload(target.assetId, { signal });
  }
  uploadPersonalPhoto(file, options = {}) { return this.uploadImage(file, { ...options, role: 'personal' }); }
  uploadProductImage(file, options = {}) { return this.uploadImage(file, { ...options, role: 'product' }); }
  createDressMeJob(input, { idempotencyKey = crypto.randomUUID(), signal } = {}) {
    if(Object.hasOwn(input,'routeProfile'))return Promise.reject(new VeloxError('Routes are assigned by Velox Admin; omit routeProfile',{status:400,code:'route_managed_by_velox'}));
    const {personalAssetId,productAssetId,notificationEndpointId,context}=input;
    return this.request('/dressme/jobs', { method: 'POST', signal,
      headers: { 'Idempotency-Key': idempotencyKey }, body: { personalAssetId, productAssetId, ...(notificationEndpointId?{notificationEndpointId}:{}),...(context?{context}:{}) } });
  }
  getDressMeJob(id, { signal } = {}) { return this.request(`/dressme/jobs/${encodeURIComponent(id)}`, { signal }); }
  createAnimateMeJob(input, { idempotencyKey = crypto.randomUUID(), signal } = {}) {
    if(Object.hasOwn(input,'routeProfile'))return Promise.reject(new VeloxError('Routes are assigned by Velox Admin; omit routeProfile',{status:400,code:'route_managed_by_velox'}));
    const {sourceAssetId,notificationEndpointId,context}=input;
    return this.request('/animateme/jobs', {method:'POST',signal,headers:{'Idempotency-Key':idempotencyKey},body:{sourceAssetId,...(notificationEndpointId?{notificationEndpointId}:{}),...(context?{context}:{})}});
  }
  getAnimateMeJob(id, { signal } = {}) { return this.request(`/animateme/jobs/${encodeURIComponent(id)}`, {signal}); }
  waitForDressMe(id, options) { return this.waitForJob('dressme',id,options); }
  waitForAnimateMe(id, options) { return this.waitForJob('animateme',id,options); }
  async waitForJob(kind, id, { timeoutMs = 120000, intervalMs = 1000, signal, onStatus } = {}) {
    if(!['dressme','animateme'].includes(kind))throw new Error('Unknown generation kind');
    if (!Number.isFinite(intervalMs) || intervalMs < 10) throw new Error('intervalMs must be at least 10');
    const deadline = AbortSignal.timeout(timeoutMs);
    const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
    while (true) {
      combined.throwIfAborted();
      const job = await (kind==='animateme'?this.getAnimateMeJob(id,{signal:combined}):this.getDressMeJob(id,{signal:combined}));
      if (onStatus) await onStatus(job);
      if (job.status === 'succeeded') return job;
      if (job.status === 'failed') throw new VeloxError(job.error?.message || 'Generation failed', { code: job.error?.code || 'generation_failed' });
      await new Promise((resolve, reject) => {
        const abort = () => { clearTimeout(timer); reject(combined.reason); };
        const timer = setTimeout(() => { combined.removeEventListener('abort', abort); resolve(); }, intervalMs);
        combined.addEventListener('abort', abort, { once: true });
        if (combined.aborted) abort();
      });
    }
  }
}

SHA-256: f1f2f180d27a923160eb4710e32728e9272093cd0035c759654b277c868df0c6