HotupdaterHot Updater
Storage Plugins

Custom Storage Plugin

Create your own storage plugin for any storage provider

Installation

npm install @hot-updater/plugin-core --save-dev

Overview

Build a custom storage plugin when the built-in providers do not match your storage backend.

The goal is small: teach Hot Updater how to save bundle artifacts, read small runtime metadata, and turn stored bundles into client-downloadable URLs. A storage plugin should not know about database rows, rollout rules, or update selection.

Storage plugins are split into environment profiles. The profile name describes where Hot Updater executes the storage code:

ProfileExecuted byMust implement
nodehot-updater deploy, hot-updater bundle promote --action copy, bundle diff generation, and console server actionsLocal file upload, local file download, and delete
runtime@hot-updater/server when createHotUpdater handles update checks in a server, serverless function, or edge runtimeClient HTTP(S) bundle URLs and direct text reads for metadata

Use the helper that matches where the plugin will run:

HelperUse when
createNodeStoragePluginThe plugin is configured in hot-updater.config.ts and runs from CLI/Node workflows such as hot-updater deploy.
createRuntimeStoragePluginThe plugin is passed to createHotUpdater({ storages }) from @hot-updater/server for update-check runtime reads.
createUniversalStoragePluginThe same plugin should work in both places: hot-updater.config.ts for CLI commands and createHotUpdater for update checks.

Most providers should use createUniversalStoragePlugin. Split providers, such as Cloudflare R2, can expose one node plugin for Wrangler-based upload and download, and a separate runtime plugin for Worker bindings.

What Universal Means

A universal storage plugin supports both sides of Hot Updater:

  • node: writes and materializes files in deploy-time tooling.
  • runtime: resolves URLs and reads small metadata in update-check code.

Universal does not mean "works everywhere with the same credentials." It means the plugin can provide both profile contracts from the configuration it receives. If your provider uses different APIs in Node and edge runtimes, keep them as separate node and runtime plugins instead.

Implementation Checklist

Before writing code, decide these values:

ItemExampleNotes
namecustomStorageUsed in diagnostics. Keep it stable and human-readable.
supportedProtocolcustom-s3Must match every storageUri the plugin returns.
Storage URI shapecustom-s3://bucket/releases/bundle.zipInclude enough information to locate the object later.
Upload key policybasePath / bundleId / filenameUse createStorageKeyBuilder unless the provider needs custom behavior.
Runtime URL policySigned URL, public CDN URL, or worker routeruntime.getDownloadUrl must return HTTP(S).
Text read policySDK call, binding read, or authenticated APIruntime.readText is for small metadata such as manifest.json.

Universal Storage Plugin

Use createUniversalStoragePlugin when one plugin can be used in both configuration surfaces:

  • storage: customStorage(...) in hot-updater.config.ts, where CLI commands such as hot-updater deploy need the node profile.
  • storages: [customStorage(...)] in createHotUpdater from @hot-updater/server, where update checks need the runtime profile.
import { createUniversalStoragePlugin } from "@hot-updater/plugin-core";

export const myStorage = createUniversalStoragePlugin<MyStorageConfig>({
  name: "myStorage",
  supportedProtocol: "custom",
  factory: (config) => ({
    node: {
      async upload(key, filePath) {
        return { storageUri: "custom://bucket/path/bundle.zip" };
      },
      async delete(storageUri) {
        // Delete the object referenced by storageUri.
      },
      async downloadFile(storageUri, filePath) {
        // Download the object referenced by storageUri into filePath.
      },
    },
    runtime: {
      async getDownloadUrl(storageUri, context) {
        return { fileUrl: "https://example.com/path/bundle.zip" };
      },
      async readText(storageUri, context) {
        return "{}";
      },
    },
  }),
});

Profile Contracts

Each method should validate that the incoming storageUri belongs to the expected provider and bucket before touching storage. That small guard prevents accidentally deleting or reading from the wrong namespace when multiple storage plugins are configured.

node.upload

Uploads a local file and returns the storage URI that Hot Updater saves in the database.

upload: (key: string, filePath: string) => Promise<{ storageUri: string }>;

key is the logical upload directory, usually the bundle ID or a child path under the bundle ID. Preserve the uploaded filename unless your provider has a strong reason not to.

Return a provider URI, not a public URL:

return {
  storageUri: `custom-s3://${bucketName}/${storageKey}`,
};

node.downloadFile

Downloads a storage object into a local path.

downloadFile: (storageUri: string, filePath: string) => Promise<void>;

Use this for provider-native downloads, not signed URLs. This is what enables copy promotion and bundle diffing for providers that cannot produce a fetch()-able URL from Node credentials.

Create the destination directory before writing the file:

await fs.mkdir(path.dirname(filePath), { recursive: true });

node.delete

Deletes the storage object referenced by storageUri.

delete: (storageUri: string) => Promise<void>;

Validate the bucket or namespace before deleting. If your provider supports folder-like prefixes, keep the delete behavior explicit and predictable.

runtime.getDownloadUrl

Returns an HTTP(S) URL that an app can download.

getDownloadUrl: (storageUri: string, context?: HotUpdaterContext) =>
  Promise<{ fileUrl: string }>;

The returned URL must be usable by the client. Runtime code rejects local file paths and non-HTTP(S) URLs.

runtime.readText

Reads small control-plane artifacts directly from storage.

readText: (storageUri: string, context?: HotUpdaterContext) =>
  Promise<string | null>;

Use the provider SDK, runtime binding, or authenticated API. Do not generate a public download URL and fetch that URL again. Hot Updater uses this path for metadata such as manifest.json.

Return null when the object does not exist. Throw for permission errors, invalid storage URIs, provider outages, and malformed provider responses.

Complete Example

This example implements a universal S3-compatible storage plugin. The same shape works for other object storage providers if you replace the SDK calls.

customStorage.ts
import fs from "node:fs/promises";
import path from "node:path";

import {
  DeleteObjectCommand,
  GetObjectCommand,
  S3Client,
  type S3ClientConfig,
} from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import {
  createStorageKeyBuilder,
  createUniversalStoragePlugin,
  getContentType,
  parseStorageUri,
} from "@hot-updater/plugin-core";

export interface CustomStorageConfig extends S3ClientConfig {
  bucketName: string;
  basePath?: string;
}

export const customStorage = createUniversalStoragePlugin<CustomStorageConfig>({
  name: "customStorage",
  supportedProtocol: "custom-s3",
  factory: (config) => {
    const { bucketName, basePath, ...clientConfig } = config;
    const client = new S3Client(clientConfig);
    const getStorageKey = createStorageKeyBuilder(basePath);

    const parseAndValidateStorageUri = (storageUri: string) => {
      const parsed = parseStorageUri(storageUri, "custom-s3");

      if (parsed.bucket !== bucketName) {
        throw new Error(
          `Bucket name mismatch: expected "${bucketName}", but found "${parsed.bucket}".`,
        );
      }

      return parsed;
    };

    return {
      node: {
        async upload(key, filePath) {
          const filename = path.basename(filePath);
          const storageKey = getStorageKey(key, filename);
          const upload = new Upload({
            client,
            params: {
              Bucket: bucketName,
              Key: storageKey,
              Body: await fs.readFile(filePath),
              ContentType: getContentType(filePath),
            },
          });

          await upload.done();

          return {
            storageUri: `custom-s3://${bucketName}/${storageKey}`,
          };
        },

        async delete(storageUri) {
          const { key } = parseAndValidateStorageUri(storageUri);

          await client.send(
            new DeleteObjectCommand({
              Bucket: bucketName,
              Key: key,
            }),
          );
        },

        async downloadFile(storageUri, filePath) {
          const { key } = parseAndValidateStorageUri(storageUri);
          const response = await client.send(
            new GetObjectCommand({
              Bucket: bucketName,
              Key: key,
            }),
          );
          const bytes = await response.Body?.transformToByteArray();

          if (!bytes) {
            throw new Error("Object body is empty");
          }

          await fs.mkdir(path.dirname(filePath), { recursive: true });
          await fs.writeFile(filePath, bytes);
        },
      },

      runtime: {
        async getDownloadUrl(storageUri) {
          const { key } = parseAndValidateStorageUri(storageUri);
          const command = new GetObjectCommand({
            Bucket: bucketName,
            Key: key,
          });

          return {
            fileUrl: await getSignedUrl(client, command, {
              expiresIn: 3600,
            }),
          };
        },

        async readText(storageUri) {
          const { key } = parseAndValidateStorageUri(storageUri);
          const response = await client.send(
            new GetObjectCommand({
              Bucket: bucketName,
              Key: key,
            }),
          );

          return response.Body?.transformToString() ?? null;
        },
      },
    };
  },
});

Node-Only Plugin

Use createNodeStoragePlugin when the plugin only needs to run from CLI/Node workflows. This is the profile used by hot-updater deploy, copy promotion, bundle diff generation, and console server actions that upload, download, or delete local files. The factory returns the node profile directly.

import { createNodeStoragePlugin } from "@hot-updater/plugin-core";

export const nodeOnlyStorage = createNodeStoragePlugin<NodeOnlyConfig>({
  name: "nodeOnlyStorage",
  supportedProtocol: "node-only",
  factory: (config) => ({
    async upload(key, filePath) {
      return { storageUri: "node-only://bucket/path/bundle.zip" };
    },
    async delete(storageUri) {},
    async downloadFile(storageUri, filePath) {},
  }),
});

Runtime-Only Plugin

Use createRuntimeStoragePlugin when the plugin only needs to run inside @hot-updater/server. This is the profile used by createHotUpdater({ storages }) while resolving update-check responses. The factory returns the runtime profile directly.

import { createRuntimeStoragePlugin } from "@hot-updater/plugin-core";

export const runtimeOnlyStorage = createRuntimeStoragePlugin<
  RuntimeConfig,
  RuntimeContext
>({
  name: "runtimeOnlyStorage",
  supportedProtocol: "runtime-only",
  factory: (config) => ({
    async getDownloadUrl(storageUri, context) {
      return { fileUrl: "https://example.com/path/bundle.zip" };
    },
    async readText(storageUri, context) {
      return "{}";
    },
  }),
});

Helper Utilities

The @hot-updater/plugin-core package provides storage helpers.

createStorageKeyBuilder

Builds storage keys with an optional base path and a filename.

const getStorageKey = createStorageKeyBuilder("releases");

getStorageKey("bundle-id", "bundle.zip");
// "releases/bundle-id/bundle.zip"

parseStorageUri

Parses storage URIs into bucket and key components.

const { bucket, key } = parseStorageUri(
  "custom-s3://my-bucket/releases/bundle.zip",
  "custom-s3",
);
// bucket: "my-bucket"
// key: "releases/bundle.zip"

getContentType

Returns a MIME type based on a file extension.

const contentType = getContentType("bundle.js");
// "application/javascript"

CLI Configuration

Use your custom plugin in hot-updater.config.ts.

import { defineConfig } from "@hot-updater/core";

import { customStorage } from "./customStorage";

export default defineConfig({
  storage: customStorage({
    region: "us-east-1",
    credentials: {
      accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    },
    bucketName: process.env.BUCKET_NAME!,
    basePath: "releases",
  }),
});

The configured plugin must implement the node profile because hot-updater CLI commands upload, download, and delete local files.

Custom Server Usage

Use the same universal plugin with createHotUpdater from @hot-updater/server. The plugin passed to storages must implement the runtime profile because update checks need download URLs and direct metadata reads.

import { createHotUpdater } from "@hot-updater/server/runtime";

import { customStorage } from "./customStorage";

const hotUpdater = createHotUpdater({
  storages: [
    customStorage({
      region: "us-east-1",
      credentials: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
      },
      bucketName: process.env.BUCKET_NAME!,
      basePath: "releases",
    }),
  ],
});

Storage URI Format

Use a stable URI format:

`${supportedProtocol}://${bucketName}/${storageKey}`;

The protocol must match supportedProtocol. Always validate the bucket or namespace before reading, writing, or deleting objects.

Avoid storing public URLs as storageUri values for provider-backed objects. The database should keep provider URIs, while runtime.getDownloadUrl should decide how to expose a temporary or public HTTP(S) URL to clients.

Testing

At minimum, cover these cases with unit tests:

  • upload returns a storageUri using the configured protocol and bucket.
  • downloadFile creates the destination file.
  • delete rejects storage URIs from a different bucket or namespace.
  • getDownloadUrl returns an HTTP(S) URL.
  • readText returns text for an existing metadata object and null for a missing one.
  • The plugin factory is not initialized until a profile method is accessed.

Security

  • Never hardcode credentials in plugin code.
  • Use environment variables or platform secret stores for sensitive values.
  • Validate storageUri before reading, downloading, or deleting.
  • Return only HTTP(S) URLs from runtime.getDownloadUrl.
  • Keep runtime.readText on direct provider reads for metadata.
  • Prefer short-lived signed URLs when storage objects are private.

On this page