Skip to main content

Contracts

WIP

The Contracts namespace — the API, transport, and strategy interfaces that connect your backend to the engine.

The Contracts namespace holds every integration point between the engine and the outside world: what your backend implements, what the transport does, and what a strategy looks like. Each concern is a sub-namespace so the names stay short and grouped.

Contracts.Api.Me

Your backend adapter implements Contracts.Api.Me<M, P, R>. Two methods are required; the rest are opt-in per feature.

import type { Contracts } from '@gentleduck/upload/core'

const api: Contracts.Api.Me<Intents, Purpose, Result> = {
  createIntent(args, ctx) { /* … */ },
  complete(args, ctx) { /* … */ },
  // findByChecksum?, multipart?, tus?
}

Every method takes call-specific args plus a context object ctx.

createIntent (required)

Asks your backend how a file should be uploaded. Returns a value from the intent map M — the strategy field on the returned object selects which registered strategy runs.

async createIntent({ purpose, contentType, size, filename, checksum, attempt }, ctx) {
  // ctx.file and ctx.signal are guaranteed present here.
  const res = await fetch('/api/uploads/intent', {
    method: 'POST',
    body: JSON.stringify({ purpose, contentType, size, filename, checksum }),
    signal: ctx.signal,
  })
  return res.json() // e.g. a PostStrategy.Intent or MultipartStrategy.Intent
}

complete (required)

Finalizes the upload after bytes land and returns your typed R.

async complete({ fileId, filename, contentType, size, checksum, attempt }, ctx) {
  const res = await fetch(`/api/uploads/${fileId}/complete`, {
    method: 'POST',
    body: JSON.stringify({ filename, contentType, size }),
    signal: ctx.signal,
  })
  return res.json() // Result
}

Use the sanitized filename from args, never the raw browser File.name.

Optional methods

MethodEnables
findByChecksum(args, ctx)Deduplication return R to skip the upload, or null
getSignedPreviewUrl(args, ctx)Fetch a signed URL for a completed file
multipart.signPart / multipart.completeMultipartRequired by the multipart strategy
multipart.listParts / multipart.abortOptional multipart housekeeping
tus.create / tus.getOffsetContract hooks for a TUS strategy

The tus methods exist in the contract, but the package does not ship a built-in TUS strategy yet — only PostStrategy and multipartStrategy. To use TUS today you'd write a custom strategy (see Strategy Registry) that consumes these methods.

The call context

Every method receives a Contracts.Api.Context<P, M>. Handy fields:

FieldNotes
localIdClient id for this item
purposeThe upload's purpose
fingerprint{ name, size, type, lastModified, checksum? } always present
attempt1-based, increments on retry
metaCaller metadata from addFiles (never persisted)
signalAbort signal (present in createIntent/complete)
fileThe File (present in most phases)
intentPresent after createIntent succeeds
stepsPhase history; errors have cause stripped

Refined contexts tighten which fields are guaranteed: CreateIntentContext guarantees file and signal; CompleteContext also guarantees intent.

Transport.Options

The transport abstracts raw HTTP so strategies stay testable. createXHRTransport() implements it with XMLHttpRequest for real progress events; the store installs it automatically.

type Options = {
  put(args):     Promise<{ etag?: string; headers?: Record<string, string> }>
  postForm(args): Promise<{ etag?: string; headers?: Record<string, string> }>
  patch(args):   Promise<{ headers?: Record<string, string> }>
}

Each method takes a url, a body/fields, an AbortSignal, and an optional onProgress callback. Provide your own Transport.Options for Node, fetch, or mocks.

Contracts.Strategy.Me

A strategy turns an intent into network calls. It is a small object:

type Me<M, C, P, R, K> = {
  id: K              // must equal the intent's `strategy` field
  resumable: boolean // can it pick up after a pause?
  start(ctx: Ctx<M, C, P, R, K>): Promise<void>
}

start receives a rich Contracts.Strategy.Ctx with file, intent, signal, the injected transport, your api, reportProgress, and cursor helpers readCursor/persistCursor. See Strategies for full details and a custom-strategy example.

Contracts.Strategy.Registry

The registry maps strategy ids to implementations and keeps the engine decoupled:

type Registry<M, C, P, R> = {
  get<K>(id: K): Me<M, C, P, R, K> | undefined
  has(id: string): id is keyof M & string
  set<K>(strategy: Me<M, C, P, R, K>): void
}

Build one with createStrategyRegistry() and populate it via .set().

Supporting shapes

  • Contracts.Result.Base{ fileId: string; key: string }; extend it as R.
  • Contracts.Intent.Base<K>{ strategy: K; fileId: string }; the base for every intent.
  • Contracts.FingerprintFile — the file identity signature (optionally with a checksum).
  • Contracts.ValidationRules — the per-purpose rules described in Store Options.
  • Contracts.Validation.Rejection — the discriminated reason a file was rejected.
  • Contracts.Errors.Error — the plain error shape carried through events (see Errors).

Upload lifecycle sequence

Loading diagram...