EdgeStore

Custom

Build a type-safe custom storage provider with defineProvider.

Use defineProvider to integrate another storage service or a custom storage implementation. The same provider instance powers the EdgeStore HTTP handler and the router-derived backend client:

const configuredEdgeStore = createEdgeStore({
  router,
  provider: myProvider,
});

createEdgeStoreNextHandler({ edgestore: configuredEdgeStore });
configuredEdgeStore.client.files.get({ key: 'files/report.pdf' });

Method inputs are contextually typed. File references, cursors, returned file fields, and provider-specific errors are inferred from the definition, so you do not need to maintain a parallel provider interface or backend-operation map.

Minimal provider

A provider must implement the upload-planning operations used by browser uploads and files.get, which EdgeStore uses to resolve a file before running router lifecycle hooks. Other operations are optional and only appear on the backend client when implemented.

This single-part example delegates storage-specific work to a small driver:

src/lib/my-provider.ts
import { defineProvider } from '@edgestore/server';
import { z } from 'zod';
import { storage } from './storage';

const baseUrl = 'https://files.example.com';

export const myProvider = defineProvider({
  name: 'my-storage',
  baseUrl,
  async init() {
    return {};
  },
  reference: {
    schema: z.object({ key: z.string().min(1) }),
    fromUrl(url) {
      return { key: new URL(url).pathname.slice(1) };
    },
  },
  uploads: {
    async request({ bucketName, fileInfo }) {
      const key = storage.createKey({ bucketName, fileInfo });
      return {
        uploadUrl: await storage.signUpload(key),
        accessUrl: `${baseUrl}/${key}`,
      };
    },
  },
  files: {
    async get({ bucketName, file }) {
      const object = await storage.head({
        bucketName,
        key: file.key,
      });
      return {
        url: `${baseUrl}/${file.key}`,
        sizeBytes: object.size,
        path: object.path,
        metadata: object.metadata,
        uploadedAt: object.createdAt,
        updatedAt: object.updatedAt,
      };
    },
  },
});

The resulting backend client exposes get, but not upload, list, or mutations, because those optional capabilities were not defined.

Complete provider

A complete provider can add direct backend uploads, pagination, mutations, and signed read URLs. Standard Schema transformations let callers use convenient input references while provider methods receive one normalized output shape:

src/lib/my-provider.ts
import { defineProvider } from '@edgestore/server';
import { z } from 'zod';
import { storage } from './storage';

const baseUrl = 'https://files.example.com';
const referenceSchema = z
  .union([z.string().url(), z.object({ key: z.string().min(1) })])
  .transform((reference) =>
    typeof reference === 'string'
      ? { key: new URL(reference).pathname.slice(1) }
      : reference,
  );

const toFile = (object: Awaited<ReturnType<typeof storage.head>>) => ({
  url: `${baseUrl}/${object.key}`,
  sizeBytes: object.size,
  path: object.path,
  metadata: object.metadata,
  uploadedAt: object.createdAt,
  updatedAt: object.updatedAt,
  etag: object.etag,
});

export const myProvider = defineProvider({
  name: 'my-storage',
  baseUrl,
  async init() {
    return {};
  },
  reference: {
    schema: referenceSchema,
    fromUrl: (url) => url,
  },
  uploads: {
    async request({ bucketName, fileInfo }) {
      return storage.createUploadPlan({ bucketName, fileInfo });
    },
    multipart: {
      async requestParts({ multipart, path }) {
        return storage.createUploadParts({ multipart, path });
      },
      async complete({ uploadId, key, parts }) {
        await storage.completeMultipart({ uploadId, key, parts });
      },
    },
    async upload({ bucketName, fileInfo, source, signal, onProgress }) {
      const object = await storage.upload({
        bucketName,
        fileInfo,
        source,
        signal,
        onProgress,
      });
      return { file: toFile(object) };
    },
  },
  files: {
    cursorSchema: z.string().min(1),
    async get({ bucketName, file }) {
      return toFile(
        await storage.head({
          bucketName,
          key: file.key,
        }),
      );
    },
    async list({ bucketName, cursor, filter, limit = 20 }) {
      const page = await storage.list({ bucketName, cursor, filter, limit });
      return {
        items: page.objects.map(toFile),
        limit,
        nextCursor: page.nextCursor,
        hasMore: page.nextCursor !== null,
      };
    },
    async confirm({ bucketName, files }) {
      return {
        results: await storage.confirm({
          bucketName,
          files,
        }),
      };
    },
    async delete({ bucketName, files }) {
      return {
        results: await storage.delete({
          bucketName,
          files,
        }),
      };
    },
    async restore({ bucketName, files }) {
      return {
        results: await storage.restore({
          bucketName,
          files,
        }),
      };
    },
    async getSignedUrls({ bucketName, files, expiresIn = 3600 }) {
      return Promise.all(
        files.map(async (file) => ({
          url: `${baseUrl}/${file.key}`,
          signedUrl: await storage.signRead({
            bucketName,
            key: file.key,
            expiresIn,
          }),
          expiresAt: new Date(Date.now() + expiresIn * 1000),
          expiresIn,
        })),
      );
    },
  },
});

Here, callers may pass either a URL or { key }, but every provider operation receives { key: string } after runtime validation. The backend client also inherits the string cursor, the extra etag file field, and any literal mutation error codes returned by the storage driver.

The backend client uses bucket-scoped names: get, list, confirm, confirmMany, delete, deleteMany, restore, restoreMany, createSignedUrl, and createSignedUrls. Provider method names remain resource operations (files.get, files.list, files.getSignedUrls) and do not need to mirror that public client surface.

get and list preserve the exact file fields inferred from the provider, including its path and metadata shapes. An upload result instead exposes the path and metadata EdgeStore computed from the router, even if the storage driver returns different placeholders.

Every file operation must enforce the logical EdgeStore bucketName. Treat bucketName and the normalized file reference as the complete storage identity, and reject a reference that belongs to another logical bucket. This keeps a frontend request authorized through bucket A from loading or mutating a file in bucket B. The official providers enforce the same ownership invariant.

Mutation providers return exactly one status for each input file, in the same order. EdgeStore attaches the original file references and derives success and failure counts for the public client. A provider result therefore contains only { success: true } or { success: false, error }; it does not repeat the file reference or calculate counts.

Multipart support is optional. A single-part provider defines only uploads.request. When uploads.multipart is present, uploads.request may return a multipart plan and EdgeStore exposes the matching part-request and completion routes.

uploads.request and uploads.upload remain separate because they perform different work: the former creates signed instructions for a browser transfer, while the latter receives bytes and performs a privileged server-side upload.

Operation groups

The provider is organized by storage resource, not by caller:

  • uploads contains browser upload planning and optional direct backend upload.
  • files contains canonical file operations shared by HTTP adapters and the backend client.
  • reference defines how frontend URLs and backend inputs become the provider's normalized file reference.

Frontend deletion still obeys the router: EdgeStore loads every file and runs beforeDelete for all of them before calling files.delete. The privileged backend client calls files.delete directly and is responsible for its own authorization.

The official EdgeStore, S3, and Azure Blob providers use defineProvider themselves and are useful reference implementations. If your provider could be useful to others, consider contributing it to EdgeStore.

On this page