Skip to main content

client

WIP

Upload client configuration, plugins, hooks, and the createUploadClient entry point.

Overview

The client layer is the public entry point for creating an upload store. It normalizes config, wires plugins, sets up the transport, and returns a stable UploadStore.

Creating a Client

createUploadClient is the recommended way to create an upload store:

src/lib/upload-client.ts
import { createUploadClient } from "@gentleduck/upload"
 
const client = createUploadClient({
  api: uploadApi,
  strategies: strategyRegistry,
  config: {
    maxConcurrentUploads: 3,
    maxAttempts: 3,
    progressThrottleMs: 100,
    autoStart: ["avatar"],
    maxItems: 100,
    validation: {
      avatar: { maxSizeBytes: 5 * 1024 * 1024, allowedTypes: ["image/*"] },
    },
  },
})
src/lib/upload-client.ts
import { createUploadClient } from "@gentleduck/upload"
 
const client = createUploadClient({
  api: uploadApi,
  strategies: strategyRegistry,
  config: {
    maxConcurrentUploads: 3,
    maxAttempts: 3,
    progressThrottleMs: 100,
    autoStart: ["avatar"],
    maxItems: 100,
    validation: {
      avatar: { maxSizeBytes: 5 * 1024 * 1024, allowedTypes: ["image/*"] },
    },
  },
})

createUploadClient is a thin alias for createUploadStore. Both return the same UploadStore interface.

Configuration

All config fields are optional. resolveUploadConfig() applies defaults at construction time:

OptionTypeDefaultDescription
maxConcurrentUploadsnumber3Maximum simultaneous uploads
progressThrottleMsnumber100Minimum interval between progress updates (ms)
maxAttemptsnumber3Maximum retry attempts per upload
maxItemsnumber100Maximum items in state before cleanup
autoStartP[] | (purpose: P) => booleanundefinedAuto-start uploads for matching purposes
validationPartial<Record<P, UploadValidationRules>>{}Per-purpose validation rules
retryPolicy(ctx) => RetryDecisionundefinedCustom retry decision function
completedItemTTLnumberundefinedAuto-remove completed items after N ms

Validation Rules

Validation rules are keyed by purpose and run during the addFiles command:

src/lib/upload-config.ts
const config = {
  validation: {
    avatar: {
      maxFiles: 1,
      maxSizeBytes: 5 * 1024 * 1024,
      minSizeBytes: 1024,
      allowedTypes: ["image/jpeg", "image/png", "image/webp"],
      allowedExtensions: [".jpg", ".jpeg", ".png", ".webp"],
    },
    document: {
      maxSizeBytes: 100 * 1024 * 1024,
      allowedTypes: ["application/pdf"],
    },
  },
}
src/lib/upload-config.ts
const config = {
  validation: {
    avatar: {
      maxFiles: 1,
      maxSizeBytes: 5 * 1024 * 1024,
      minSizeBytes: 1024,
      allowedTypes: ["image/jpeg", "image/png", "image/webp"],
      allowedExtensions: [".jpg", ".jpeg", ".png", ".webp"],
    },
    document: {
      maxSizeBytes: 100 * 1024 * 1024,
      allowedTypes: ["application/pdf"],
    },
  },
}

Files that fail validation are rejected with a RejectReason and emitted as file.rejected events. They never enter the state machine.

Retry Policy

Default behavior retries up to maxAttempts with exponential backoff. Override with a custom retryPolicy:

src/lib/retry-policy.ts
const config = {
  retryPolicy: ({ phase, attempt, error }) => {
    // Never retry auth errors
    if (error.code === "auth") return { retryable: false }
 
    // Respect rate limit headers
    if (error.code === "rate_limit" && error.retryAfterMs) {
      return { retryable: true, delayMs: error.retryAfterMs }
    }
 
    // Exponential backoff with cap
    const delayMs = Math.min(500 * 2 ** (attempt - 1), 10_000)
    return { retryable: true, delayMs }
  },
}
src/lib/retry-policy.ts
const config = {
  retryPolicy: ({ phase, attempt, error }) => {
    // Never retry auth errors
    if (error.code === "auth") return { retryable: false }
 
    // Respect rate limit headers
    if (error.code === "rate_limit" && error.retryAfterMs) {
      return { retryable: true, delayMs: error.retryAfterMs }
    }
 
    // Exponential backoff with cap
    const delayMs = Math.min(500 * 2 ** (attempt - 1), 10_000)
    return { retryable: true, delayMs }
  },
}

The RetryDecision type is either { retryable: false } or { retryable: true; delayMs: number }.

Store Options

The StoreOptions interface holds everything needed to build the store runtime:

OptionRequiredDescription
apiYesBackend API implementing UploadApi
strategiesYesStrategy registry with registered strategies
configNoUpload configuration (defaults applied)
transportNoHTTP transport (defaults to createXHRTransport())
persistenceNoPersistence adapter and options
pluginsNoArray of plugins
hooksNoLifecycle hooks
fingerprintNoCustom file fingerprinting function
validateFileNoCustom validation function (runs after built-in rules)
errorNormalizerNoCustom error normalizer for raw errors
initialStateNoPre-hydrated state (e.g., from persistence)

Plugins

Plugins extend the store's behavior without forking the engine. Each plugin gets a minimal proxy with on, dispatch, and getSnapshot — enough to observe events and drive behavior, not enough to break internal state.

src/lib/plugins/analytics.ts
import type { UploadPlugin } from "@gentleduck/upload"
 
const analyticsPlugin: UploadPlugin<MyIntents, MyCursors, Purpose, MyResult> = {
  name: "analytics",
  setup({ on, getSnapshot }) {
    on("upload.completed", ({ localId, result }) => {
      const item = getSnapshot().items.get(localId)
      trackEvent("upload_completed", {
        fileId: result.fileId,
        purpose: item?.purpose,
      })
    })
 
    on("upload.error", ({ localId, error }) => {
      trackEvent("upload_error", {
        code: error.code,
        message: error.message,
      })
    })
  },
}
 
const client = createUploadClient({
  api: uploadApi,
  strategies,
  plugins: [analyticsPlugin],
})
src/lib/plugins/analytics.ts
import type { UploadPlugin } from "@gentleduck/upload"
 
const analyticsPlugin: UploadPlugin<MyIntents, MyCursors, Purpose, MyResult> = {
  name: "analytics",
  setup({ on, getSnapshot }) {
    on("upload.completed", ({ localId, result }) => {
      const item = getSnapshot().items.get(localId)
      trackEvent("upload_completed", {
        fileId: result.fileId,
        purpose: item?.purpose,
      })
    })
 
    on("upload.error", ({ localId, error }) => {
      trackEvent("upload_error", {
        code: error.code,
        message: error.message,
      })
    })
  },
}
 
const client = createUploadClient({
  api: uploadApi,
  strategies,
  plugins: [analyticsPlugin],
})

Plugin Guidelines

  • Plugins should be read-only — don't mutate state directly.
  • Use dispatch only for well-defined side effects (e.g. auto-retry).
  • Plugin setup errors are caught and logged in development mode.
  • Plugin names are for debugging — keep them descriptive.

Hooks

Hooks are a lower-level observation point than plugins. onInternalEvent fires after each internal event, giving full fidelity into engine behavior:

src/lib/devtools.ts
const client = createUploadClient({
  api: uploadApi,
  strategies,
  hooks: {
    onInternalEvent(event, state) {
      console.log("[upload]", event.type, event)
    },
  },
})
src/lib/devtools.ts
const client = createUploadClient({
  api: uploadApi,
  strategies,
  hooks: {
    onInternalEvent(event, state) {
      console.log("[upload]", event.type, event)
    },
  },
})

Custom Fingerprinting

The engine fingerprints files using name, size, type, and lastModified by default. Provide a custom function for stronger identity (e.g. a checksum):

src/lib/upload-client.ts
const client = createUploadClient({
  api: uploadApi,
  strategies,
  fingerprint: (file) => ({
    name: file.name,
    size: file.size,
    type: file.type,
    lastModified: file.lastModified,
    checksum: computeSHA256(file), // must be synchronous
  }),
})
src/lib/upload-client.ts
const client = createUploadClient({
  api: uploadApi,
  strategies,
  fingerprint: (file) => ({
    name: file.name,
    size: file.size,
    type: file.type,
    lastModified: file.lastModified,
    checksum: computeSHA256(file), // must be synchronous
  }),
})

The fingerprint function must be synchronous to keep addFiles fast.

Next Steps

  • Engine — state machine, scheduling, and retry details.
  • Contracts — the UploadApi, transport, and strategy interfaces.
  • Persistence — configure state persistence.

Client FAQ