HotupdaterHot Updater
Create an Adapter

Create a storage adapter

Store bundle artifacts on any object store with createStorageAdapter, serve their downloads, and test the adapter.

What a storage adapter does

A storage adapter keeps the files that hot-updater deploy uploads, such as manifests, assets, archives, and patches, and resolves the URLs devices download them from. It is one object: a name, the URI protocol it owns, and the operations its consumers call. The CLI, the Console, and the server use the same object, so the operations take plain inputs and Web streams. The factory that creates the adapter captures SDK clients, bindings, and credentials.

Each consumer checks the operations it needs, and fails with Storage adapter "<name>" does not implement <operation>. when one is missing:

ConsumerOperations
hot-updater deployput, get, exists, delete
hot-updater patchget, put, delete
hot-updater bundle promoteNone; it reuses the stored artifact
hot-updater storage pruneget, listObjects, and deleteObjects with --yes
Consoleget, put, exists, delete
The server's hotUpdater.handlersget, getDownloadUrl, from every adapter

An adapter for general use implements put, get, getDownloadUrl, exists, and delete. listObjects and deleteObjects add storage cleanup.

Install

npm install @hot-updater/plugin-core
npm install @hot-updater/test-utils vitest --save-dev

@hot-updater/plugin-core exports createStorageAdapter, the storage types, and the URI helpers used below. An adapter you publish lists it in peerDependencies; see Test and publish. @hot-updater/test-utils runs the storage contract against your adapter; see Test the adapter.

Write the adapter

This adapter stores objects through a BucketClient, the calls it needs from your provider's SDK. Replace it with your SDK's client:

src/myStorage.ts
import {
  createStorageKeyBuilder,
  createStorageAdapter,
  createStorageUri,
  parseStorageUri,
} from "@hot-updater/plugin-core";

/** The calls the adapter makes to your provider's SDK. */
export interface BucketClient {
  upload(
    key: string,
    body: ReadableStream<Uint8Array>,
    options: { contentType: string; contentLength?: number },
  ): Promise<void>;
  download(key: string): Promise<{
    body: ReadableStream<Uint8Array>;
    contentType: string;
    size: number;
  } | null>;
  /** A URL a device downloads the object from, such as a presigned one. */
  downloadUrl(key: string): Promise<string>;
  exists(key: string): Promise<boolean>;
  remove(key: string): Promise<void>;
}

export interface MyStorageOptions {
  client: BucketClient;
  bucket: string;
  /** A folder inside the bucket for Hot Updater's objects. */
  basePath?: string;
}

const PROTOCOL = "my-storage";

export const myStorage = ({ client, bucket, basePath }: MyStorageOptions) => {
  const objectKey = createStorageKeyBuilder(basePath);
  const keyOf = (storageUri: string) => {
    const parsed = parseStorageUri(storageUri, PROTOCOL);
    if (parsed.bucket !== bucket) {
      throw new Error(`Expected bucket "${bucket}", got "${parsed.bucket}".`);
    }
    return parsed.key;
  };

  return createStorageAdapter({
    name: "myStorage",
    protocol: PROTOCOL,

    async put({ key, body, contentLength, contentType }) {
      const storedKey = objectKey(key);
      await client.upload(storedKey, body, { contentType, contentLength });
      return {
        storageUri: createStorageUri({
          protocol: PROTOCOL,
          bucket,
          key: storedKey,
        }),
      };
    },

    async get({ storageUri }) {
      const object = await client.download(keyOf(storageUri));
      if (object === null) return { response: null };
      return {
        response: new Response(object.body, {
          headers: {
            "content-type": object.contentType,
            "content-length": String(object.size),
          },
        }),
      };
    },

    async getDownloadUrl({ storageUri }) {
      return { url: await client.downloadUrl(keyOf(storageUri)) };
    },

    async exists({ storageUri }) {
      return { exists: await client.exists(keyOf(storageUri)) };
    },

    async delete({ storageUri }) {
      await client.remove(keyOf(storageUri));
      return { deleted: true };
    },
  });
};
  • protocol is the scheme of every URI the adapter stores, such as s3 or r2. The database keeps those URIs, so the protocol cannot change once Bundles use it, and a server registers one adapter per protocol. Choose one that names your provider.
  • put receives a key relative to the adapter's base path, such as bundles/<bundle-id>/manifest.json or assets/sha256/ab/<hash>.png, and a one-shot ReadableStream. Consume or cancel the stream before the promise settles. contentLength is set when the consumer knows the size.
  • put returns the URI of that key below the base path, from createStorageUri. Deploy and the server derive the URIs of related objects from it, such as the archive next to a manifest and the shared assets/ folder, and pass them to get and exists, so an adapter must store every key at the same place relative to its base path.
  • get returns a Response whose body streams the object, or null when it does not exist. Consumers pipe the body without buffering it.
  • exists tells deploy whether it can reuse an object. Return true only when a device could download it.
  • delete removes exactly one object and returns { deleted: true }, also when the object is already gone, so a retried cleanup succeeds.
  • createStorageKeyBuilder(basePath) joins the base path and a key, and rejects ., .., and \ segments.
  • parseStorageUri(uri, protocol) checks the scheme and decodes the key. Check the bucket too, in every operation that takes a URI, getDownloadUrl included, so an adapter never reads or signs another bucket's URI.

Each operation receives one input object and returns one result object, so the contract can add fields without changing positions.

Serve downloads

getDownloadUrl({ storageUri }) returns the URL a device downloads an object from: an absolute http or https URL that your provider serves, such as a public or CDN URL, or one your provider signs, such as an S3 presigned URL. The server calls it for every artifact in an update response, and the device starts the download right away, so a signed URL needs to last only minutes; s3Storage and r2Storage sign theirs for an hour. Only the server needs getDownloadUrl.

Register the adapter

The CLI takes one storage adapter as storage. This excerpt shows only that field of a complete hot-updater.config.ts:

hot-updater.config.ts (storage excerpt)
import { createBucketClient } from "./src/bucketClient";
import { myStorage } from "./src/myStorage";

const storage = myStorage({
  client: createBucketClient(process.env.BUCKET_TOKEN!),
  bucket: "bundles",
  basePath: "ota",
});

Assign this value to storage next to the configuration's build, database, plugins, and updateStrategy.

The server takes the same adapter, with the same bucket and base path as the CLI:

src/hotUpdater.ts (excerpt)
export const hotUpdater = createHotUpdater({
  database, // Your provider's database.
  storage: myStorage({
    client: createBucketClient(process.env.BUCKET_TOKEN!),
    bucket: "bundles",
    basePath: "ota",
  }),
  plugins: [insights(), apiKeys()],
});

The first read of hotUpdater.handlers checks that storage implements get and getDownloadUrl, so a server that mounts the handlers throws there when its adapter is upload-only. To answer a URI, Hot Updater:

  1. uses the adapter when its protocol matches the URI's scheme;
  2. uses an http or https URI directly when no adapter owns its scheme;
  3. otherwise reports the scheme as unsupported.

An adapter that owns http or https takes precedence, so a provider can apply authorization to those URIs as well.

Provide runtime-specific entries

A provider can create the same adapter in different ways for different runtimes, and export each from its own entry. @hot-updater/cloudflare exports r2Storage over S3-compatible credentials for the CLI, and @hot-updater/cloudflare/worker exports an r2Storage over a Worker's R2 binding. Both use the r2 protocol and the same key layout, so each reads the URIs the other stores:

package.json (excerpt)
{
  "exports": {
    ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
    "./worker": {
      "types": "./dist/worker.d.ts",
      "import": "./dist/worker.js"
    }
  }
}

Support storage cleanup

hot-updater storage prune removes objects that no Bundle references. It needs two more operations:

  • listObjects(prefix?) lists the objects below an optional prefix, relative to the base path. Each StorageObject has key, storageUri, size, and an optional lastModifiedAt. Pruning keeps objects without a modification time.
  • deleteObjects(keys) deletes exactly the keys it receives, relative to the base path.

Without them, storage prune reports that the adapter does not support it. Prefix or bucket deletion is outside the contract.

Test the adapter

setupStorageAdapterTestSuite from @hot-updater/test-utils runs the storage contract that deploy, patch, the Console, the server, and storage prune rely on. Every official storage adapter runs it. Run it over a fake of your SDK's client first. This spec keeps objects in memory:

src/myStorage.spec.ts
import { setupStorageAdapterTestSuite } from "@hot-updater/test-utils";

import { type BucketClient, myStorage } from "./myStorage";

/** Keeps objects in memory in place of the provider. */
const memoryBucket = (): BucketClient => {
  const objects = new Map<
    string,
    { bytes: ArrayBuffer; contentType: string }
  >();
  return {
    async upload(key, body, { contentType }) {
      const bytes = await new Response(body).arrayBuffer();
      objects.set(key, { bytes, contentType });
    },
    async download(key) {
      const object = objects.get(key);
      if (object === undefined) return null;
      return {
        body: new Blob([object.bytes]).stream(),
        contentType: object.contentType,
        size: object.bytes.byteLength,
      };
    },
    async downloadUrl(key) {
      return `https://downloads.example.com/${encodeURIComponent(key)}`;
    },
    async exists(key) {
      return objects.has(key);
    },
    async remove(key) {
      objects.delete(key);
    },
  };
};

setupStorageAdapterTestSuite({
  name: "myStorage",
  createStorage: async () => ({
    storage: myStorage({
      client: memoryBucket(),
      bucket: "bundles",
      basePath: "ota",
    }),
    basePath: "ota",
  }),
  operations: ["put", "get", "getDownloadUrl", "exists", "delete"],
});
OptionMeaning
nameNames the suite
createStorageReturns { storage, basePath?, cleanup? } over an empty bucket or base path, for each case; cleanup runs after the case. With basePath, every URI put returns must have the key <basePath>/<key>.
operationsThe operations the adapter implements. The suite fails when one of them, put, or get is missing, and skips the cases of the others. Add listObjects and deleteObjects when the adapter supports storage cleanup.
fetchDownloadUrlsFetches every http(s) URL getDownloadUrl returns and requires the object's bytes. Set it in a spec against a real bucket or emulator whose URLs the test can reach.

The cases cover the name and protocol; bodies of known, unknown, and zero length, each read to the end before put resolves; concurrent puts; the canonical URI of each key below the base path, with spaces, #, %, @, +, and Unicode; the archive and asset URIs that deploy derives from a manifest's URI; replacing an object; a failed upload that leaves no object; null for a missing object or a folder; a delete that removes exactly one object and succeeds again; a URI of another bucket or protocol, which every operation rejects; download URLs that are absolute http(s) URLs, and with fetchDownloadUrls, URLs that serve the object; keys that listObjects returns relative to the base path, with a prefix as a folder; and deleteObjects deleting exactly the keys it receives.

Before you publish, run the suite against a real bucket or your provider's emulator too. Give each case a base path of its own, and delete what it stored:

src/myStorage.integration.spec.ts
import { setupStorageAdapterTestSuite } from "@hot-updater/test-utils";

import { myStorage } from "./myStorage";
import { deleteBelow, testBucketClient } from "./testBucket";

const client = testBucketClient();

setupStorageAdapterTestSuite({
  name: "myStorage (test bucket)",
  createStorage: async () => {
    const basePath = `conformance/${crypto.randomUUID()}`;
    return {
      storage: myStorage({
        client,
        bucket: "hot-updater-test",
        basePath,
      }),
      basePath,
      cleanup: () => deleteBelow(basePath),
    };
  },
  operations: ["put", "get", "getDownloadUrl", "exists", "delete"],
  fetchDownloadUrls: true,
});

./testBucket is your own module: your SDK's client for a test bucket, and a function that deletes every object below a prefix. The s3Storage spec runs the suite this way on LocalStack.

Then deploy through it: point a test app's hot-updater.config.ts at the adapter, run hot-updater deploy, and test an OTA update against a server that registers the same adapter.

Storage contract

interface StorageAdapter {
  readonly name: string;
  readonly protocol: string;

  readonly put?: (input: {
    readonly key: string;
    readonly body: ReadableStream<Uint8Array>;
    readonly contentLength?: number;
    readonly contentType: string;
  }) => Promise<{ readonly storageUri: string }>;

  readonly get?: (input: {
    readonly storageUri: string;
  }) => Promise<{ readonly response: Response | null }>;

  readonly getDownloadUrl?: (input: {
    readonly storageUri: string;
  }) => Promise<{ readonly url: string }>;

  readonly exists?: (input: {
    readonly storageUri: string;
  }) => Promise<{ readonly exists: boolean }>;

  readonly delete?: (input: {
    readonly storageUri: string;
  }) => Promise<{ readonly deleted: true }>;

  readonly listObjects?: (prefix?: string) => Promise<StorageObject[]>;
  readonly deleteObjects?: (keys: readonly string[]) => Promise<void>;
}

createStorageAdapter returns the object it receives, typed with the operations it has. StorageAdapterWith<"get" | "put"> names an adapter that has those operations.

Storage URI format

A stored location is a hierarchical URI:

protocol://bucket/encoded/slash/key

The bucket is the URI host. Each key segment is encoded on its own, so / separates segments while spaces, Unicode, #, ?, and % round-trip. Create and parse URIs with the helpers instead of joining strings:

const storageUri = createStorageUri({
  protocol: "my-storage",
  bucket: "bundles",
  key: "releases/1.0.0/bundle ios#1.zip",
});
// my-storage://bundles/releases/1.0.0/bundle%20ios%231.zip

const parsed = parseStorageUri(storageUri, "my-storage");
// { protocol: "my-storage", bucket: "bundles",
//   key: "releases/1.0.0/bundle ios#1.zip" }

A protocol starts with a lowercase letter and uses lowercase letters, digits, +, ., and -. Empty, ., and .. key segments, query strings, and fragments are not valid locations; the helpers reject them instead of normalizing to another object.

File-system consumers

The contract carries Web streams and responses, not paths. The CLI opens a local file as a stream, passes its size as contentLength, and hands the stream to put without reading the file into memory. Keep file-system operations out of an adapter.

On this page