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:
| Consumer | Operations |
|---|---|
hot-updater deploy | put, get, exists, delete |
hot-updater patch | get, put, delete |
hot-updater bundle promote | None; it reuses the stored artifact |
hot-updater storage prune | get, listObjects, and deleteObjects with --yes |
| Console | get, put, exists, delete |
The server's hotUpdater.handlers | get, 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:
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 };
},
});
};protocolis the scheme of every URI the adapter stores, such ass3orr2. 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.putreceives a key relative to the adapter's base path, such asbundles/<bundle-id>/manifest.jsonorassets/sha256/ab/<hash>.png, and a one-shotReadableStream. Consume or cancel the stream before the promise settles.contentLengthis set when the consumer knows the size.putreturns the URI of that key below the base path, fromcreateStorageUri. Deploy and the server derive the URIs of related objects from it, such as the archive next to a manifest and the sharedassets/folder, and pass them togetandexists, so an adapter must store every key at the same place relative to its base path.getreturns aResponsewhose body streams the object, ornullwhen it does not exist. Consumers pipe the body without buffering it.existstells deploy whether it can reuse an object. Returntrueonly when a device could download it.deleteremoves 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,getDownloadUrlincluded, 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:
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:
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:
- uses the adapter when its
protocolmatches the URI's scheme; - uses an
httporhttpsURI directly when no adapter owns its scheme; - 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:
{
"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. EachStorageObjecthaskey,storageUri,size, and an optionallastModifiedAt. 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:
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"],
});| Option | Meaning |
|---|---|
name | Names the suite |
createStorage | Returns { 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>. |
operations | The 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. |
fetchDownloadUrls | Fetches 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:
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/keyThe 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.
BugSnag
The `withBugsnag()` plugin for hot-updater enables automatic sourcemap upload to BugSnag during the update bundle build process.
Create a database adapter
Run Hot Updater's database on your backend with a SQL executor, a key-value store, or a DatabaseAdapter, and test it with the published suites.