Skip to content

Itzfeminisce/uplift

Repository files navigation

Uplift logo

Uplift

npm version npm downloads npm downloads total CI bundle size license

Dead-simple, type-safe file uploads for TypeScript applications.

Define upload routes once on the server. Get a typed client on the frontend.

await upload.avatar(file);
await upload.gallery(files);

Uplift is for people who want file uploads to feel like a TypeScript contract instead of a pile of multipart parsing, storage SDK calls, auth checks, and duplicated client code.

Why Uplift?

  • Typed from server to client: route names, file multiplicity, and return values are inferred.
  • Fluent server API: image().max("2mb").auth(...).key(...).done(...).
  • Single endpoint: the client posts to one upload endpoint with a route query.
  • Framework adapters: Next.js, Hono, Express, Fastify, Elysia, SvelteKit, Remix, TanStack Start, and Nuxt entrypoints.
  • Storage adapters: S3, R2, Bunny, Cloudinary, local, memory, and UploadThing-compatible storage.
  • No schema lock-in: JSON validation accepts any .parse() schema.
  • Small core: framework and storage adapters live in separate packages, so users install only what they use.
  • Package hardening: docs snippets, example app, bundle size, and install smoke checks run before publish.

Install

Install the core plus the adapters you use:

pnpm add @uplift-io/uplift @uplift-io/next @uplift-io/s3
npm install @uplift-io/uplift @uplift-io/next @uplift-io/s3
yarn add @uplift-io/uplift @uplift-io/next @uplift-io/s3

Other adapters are available as separate packages: @uplift-io/hono, @uplift-io/express, @uplift-io/fastify, @uplift-io/elysia, @uplift-io/sveltekit, @uplift-io/remix, @uplift-io/tanstack-start, @uplift-io/nuxt, @uplift-io/local, @uplift-io/memory, @uplift-io/r2, @uplift-io/bunny, @uplift-io/cloudinary, and @uplift-io/uploadthing.

Media capability packages are optional: add @uplift-io/image for image transforms and variants, and @uplift-io/video for synchronous video transforms and derived artifacts.

Async transforms are available for background media work. Use .transform(...) when work should finish during the upload request, and .transformAsync(...) when the request should accept the Original Upload, create a Transform Job, and let a worker produce the final result later.

Quick Start

1. Define server routes

// uploads.ts
import { csv, image, pdf, uplift } from "@uplift-io/uplift";
import { s3 } from "@uplift-io/s3";

export const uploads = uplift({
  storage: s3({
    bucket: process.env.S3_BUCKET!,
    region: "us-east-1",
    accessKeyId: process.env.S3_ACCESS_KEY_ID!,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!
  }),

  routes: {
    avatar: image()
      .max("2mb")
      .auth(async ({ req }) => {
        return { id: req.headers.get("x-user-id")! };
      })
      .headers(({ user }) => ({
        "Cache-Control": "public, max-age=31536000",
        "Content-Disposition": `inline; filename="${user.id}.png"`
      }))
      .key(({ user }) => `avatars/${user.id}.png`)
      .done(async ({ file, user }) => {
        console.log("avatar uploaded", user.id, file.url);
      }),

    resume: pdf().max("5mb"),

    contacts: csv().columns(["email", "name"], { delimiter: "," }),

    gallery: image()
      .max("8mb")
      .multiple(10)
      .auth(async ({ req }) => {
        return { id: req.headers.get("x-user-id")! };
      })
      .key(({ user, file }) => `gallery/${user.id}/${file.name}`)
      .done(async ({ files }) => {
        console.log(files.map((file) => file.url));
      })
  }
});

export type Uploads = typeof uploads;

Routes are single-file by default. Calling .multiple() changes:

  • the server done() context from { file } to { files }
  • the client input from File to File[] | FileList
  • the client result from UploadedFile to UploadedFile[]

2. Mount a framework handler

Next.js App Router:

// app/api/upload/route.ts
import { createNextHandler } from "@uplift-io/next";
import { uploads } from "@/uploads";

export const { HEAD, GET, POST } = createNextHandler(uploads);

Hono:

import { Hono } from "hono";
import { createHonoHandler } from "@uplift-io/hono";
import { uploads } from "./uploads";

const app = new Hono();
app.route("/upload", createHonoHandler(uploads));

Express:

import express from "express";
import { createExpressHandler } from "@uplift-io/express";
import { uploads } from "./uploads";

const app = express();
app.use("/upload", createExpressHandler(uploads));

3. Create a typed client

// upload-client.ts
import { createUploadClient } from "@uplift-io/uplift/client";
import type { Uploads } from "./uploads";

export const upload = createUploadClient<Uploads>("/api/upload");
const avatar = await upload.avatar(file);
avatar.url;
avatar.key;

const gallery = await upload.gallery(fileList);
gallery[0].url;

The client posts to the configured endpoint with ?route=<routeName>, so one endpoint can host every route.

Route methods also expose operation controls:

upload.avatar.abort();
await upload.avatar.retry();

const check = await upload.avatar.preflight(file);
if (check.ok) {
  await check.upload();
}

Async Transforms

Async transform routes return an AsyncTransformHandle with id, route, status, and done(). The correctness baseline is polling through the upload endpoint; realtime transports can layer on top without changing terminal behavior. Job ids are bearer tokens for status reads, so treat them as sensitive.

import { asyncTransforms, type RedisLike } from "@uplift-io/redis";
import { uplift, video } from "@uplift-io/uplift";
import { createUploadClient } from "@uplift-io/uplift/client";
import { runNextTransformJob } from "@uplift-io/uplift/server";
import { thumbnail, transcode, trim } from "@uplift-io/video";

declare const redis: RedisLike;

export const uploads = uplift({
  storage,
  asyncTransforms: asyncTransforms(redis, {
    queueName: "uplift:async-transforms",
    keepOriginal: "failed",
    timeout: "10m"
  }),
  routes: {
    clip: video()
      .transformAsync(trim({ start: "00:00:01" }), transcode({ format: "mp4" }), { timeout: "10m" })
      .outputs(thumbnail("poster", { at: "25%" }))
      .listeners({
        queued: ({ id }) => console.log("queued", id),
        completed: ({ result }) => result.outputs?.poster,
        failed: ({ error }) => console.error(error.message)
      })
  }
});

const upload = createUploadClient<typeof uploads>("/api/upload");
const transform = await upload.clip(file);
const completed = await transform.done({ timeoutMs: 60_000 });
completed.output("poster");

await runNextTransformJob(uploads);

@uplift-io/redis accepts a RedisLike object instead of depending on a specific Redis client package. queueName is required because it is the Redis namespace shared by the web app, status reads, and workers for one compatible Upload Contract; do not reuse a queue name across incompatible route definitions or environments. Redis claims are leased and recoverable, with claimVisibilityTimeoutMs defaulting to five minutes. Workers validate the queued route contract before running so removed routes or changed transform/output semantics fail clearly instead of silently processing with the wrong code.

Async Transform Jobs are single-file routes. Durable queues require storage adapters that can read Original Upload bytes; Local, S3, and R2 adapters support this. Do not combine .transform(...) and .transformAsync(...) on the same route. keepOriginal controls Original Upload retention after terminal states: false deletes after success or failure, "failed" keeps failed originals only, and true keeps originals after every terminal state. It defaults to "failed". Route listeners are best-effort diagnostics; .done(...) remains the strict completion hook and can fail the Transform Job.

abort() only cancels the active attempt for that route method. retry() reuses the most recent failed or aborted input while the client instance is alive. preflight() sends file facts only, then check.upload() uses the same upload machinery as a direct route call.

Server handlers expose the standard HTTP surface:

  • HEAD returns a bodyless health response.
  • GET returns the public Route Manifest.
  • POST handles upload attempts and preflight checks.

Generate OpenAPI from the public manifest with @uplift-io/openapi:

import { createRouteManifest } from "@uplift-io/uplift/server";
import { createOpenApiDocument } from "@uplift-io/openapi";

export const openapi = createOpenApiDocument(createRouteManifest(uploads), {
  path: "/api/upload"
});

React

import { useUploads } from "@uplift-io/uplift/react";
import type { Uploads } from "./uploads";

export function AvatarUploader() {
  const upload = useUploads<Uploads>("/api/upload");

  return (
    <input
      type="file"
      accept="image/*"
      onChange={(event) => {
        const file = event.currentTarget.files?.[0];
        if (file) void upload.avatar(file);
      }}
    />
  );
}

Each route method exposes state:

upload.avatar.progress;
upload.avatar.isUploading;
upload.avatar.error;
upload.avatar.data;

In browsers, Uplift uses XHR for real upload progress. In non-browser or custom-fetch environments, progress is lifecycle-based.

File Builders

import {
  any,
  audio,
  csv,
  custom,
  image,
  json,
  pdf,
  text,
  video
} from "@uplift-io/uplift";

Shared methods:

.max("2mb")
.min("10kb")
.multiple(10)
.auth(async ({ req }) => user)
.overrideAuth()
.key(({ user, file }) => `uploads/${file.name}`)
.meta(({ user, file }) => ({ owner: user.id, name: file.name }))
.validate(({ file }) => true)
.headers({ "Cache-Control": "public, max-age=31536000" })
.done(async ({ file, user, meta }) => {})

JSON schema validation accepts any object with a .parse() method:

json().schema(zodSchema);

headers() is shared by every route builder and means object-storage headers. It accepts a static object or a function that can read req, file, user, and meta.

CSV file structure uses columns():

csv().columns(["email", "name"], { delimiter: "," });
csv().delimiter(";").columns(["email", "name"]);

If both columns(..., { delimiter }) and delimiter() are used, the last delimiter call wins. Migrate old CSV column checks from csv().headers([...]) to csv().columns([...]).

There is no hard dependency on Zod, Axios, an ORM, or a database client.

Media Processing

Core Uplift owns the typed pipeline, but media packages own media dependencies and domain APIs. .transform() changes the primary uploaded file before key generation and storage. .outputs() creates named artifacts after primary transforms have finished.

import { image, video } from "@uplift-io/uplift";
import { resize, convert, variant } from "@uplift-io/image";
import { trim, transcode, thumbnail, poster } from "@uplift-io/video";

const routes = {
  avatar: image()
    .transform(resize({ width: 512, height: 512, fit: "cover" }), convert("webp"))
    .outputs(
      variant("thumb", resize({ width: 96, height: 96 }), convert("webp")),
      variant("preview", resize({ width: 320 }), convert("webp"))
    ),

  clip: video()
    .transform(trim({ start: "00:00:01", end: "00:00:10" }), transcode({ format: "mp4", codec: "h264" }))
    .outputs(thumbnail("thumb", { at: "25%" }), poster("poster", { at: "00:00:02" }))
};

The frontend upload method shape is unchanged:

const avatar = await upload.avatar(file);
avatar.output("thumb").url;

const clip = await upload.clip(videoFile);
clip.output("poster").url;

Output keys use the v1 convention <primary-key>/outputs/<name>.<extension>. Outputs derive from the transformed primary file, and any transform, output, done(), or onUploadComplete failure fails the upload request. Storage adapters can implement delete(key) to let core roll back already-written objects during failed requests.

@uplift-io/image uses Sharp internally. @uplift-io/video shells out to ffmpeg/ffprobe during the upload request; production hosts should install those binaries or set UPLIFT_FFMPEG_PATH and UPLIFT_FFPROBE_PATH, and should keep request-time budgets appropriate for the selected work.

Storage

import { bunny } from "@uplift-io/bunny";
import { cloudinary } from "@uplift-io/cloudinary";
import { local } from "@uplift-io/local";
import { r2 } from "@uplift-io/r2";
import { s3 } from "@uplift-io/s3";
import { uploadthing } from "@uplift-io/uploadthing";

S3 and R2 use @aws-sdk/client-s3 and support safe object headers plus rollback deletion. Bunny supports safe object headers and deletes through Bunny Storage. Local and memory storage implement cleanup. Custom adapters can omit delete, but rollback cannot remove files for adapters that do not expose it.

Cloudinary unsigned uploads still work with cloudName and uploadPreset; rollback cleanup is enabled only when server-side apiKey and apiSecret are configured. Cloudinary does not provide generic object-storage header parity, so route headers are not mapped there.

The UploadThing adapter accepts a server-side uploader compatible with UTApi.uploadFiles() and an optional deleter compatible with UTApi.deleteFiles(), keeping UploadThing as an optional integration rather than a core dependency.

import { uploadthing } from "@uplift-io/uploadthing";
import { UTApi } from "uploadthing/server";

const utapi = new UTApi();

const storage = uploadthing({
  uploader: (file) => utapi.uploadFiles(file),
  deleter: (key) => utapi.deleteFiles(key)
});

Examples

Run the dogfood checks:

pnpm docs:check
pnpm examples:check
pnpm smoke:pack

Bundle Size

Bundle size is generated from the built package, not a remote badge service:

pnpm build
pnpm bundle:size

See docs/BUNDLE_SIZE.md.

Legacy Rich Inspection

@uplift-io/rich is legacy. Prefer @uplift-io/image and @uplift-io/video for media behavior. Existing inspection-heavy routes remain opt-in:

import { audio, pdf, video } from "@uplift-io/rich";

pdf().pages({ max: 10 }).encrypted(false);
video().duration({ max: "2m" });
audio().duration({ max: "5m" });

Rich inspection methods currently fail closed until an inspector is wired for the deployment. Video duration checks are designed around ffprobe; hosts using that legacy feature must provide ffprobe and should verify availability in deployment.

Documentation

Comparison

Uplift is not trying to replace every upload tool.

  • Choose Uplift when the upload contract should be typed from server route to client call, and storage should stay swappable.
  • Choose UploadThing when you want managed upload infrastructure and first-party UI helpers. Uplift can use it as a storage boundary.
  • Choose Uppy or FilePond when the browser upload widget is the product surface.
  • Choose direct SDKs when you need bespoke storage behavior and do not need route inference.

Project Status

Uplift is at 1.0.0. The package split is the stable install model: install @uplift-io/uplift for core APIs, then add only the framework and storage adapters your app uses.

Contributing

Issues and PRs are welcome. Good contributions usually include:

  • a small failing test that captures the behavior
  • the minimal implementation to make it pass
  • docs updates when the public API changes

Run the project locally:

pnpm install
pnpm check

License

MIT

Releases

Packages

Contributors

Languages