Custom Storage Plugin
Create your own storage plugin for any storage provider
Installation
npm install @hot-updater/plugin-core --save-devOverview
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:
| Profile | Executed by | Must implement |
|---|---|---|
node | hot-updater deploy, hot-updater bundle promote --action copy, bundle diff generation, and console server actions | Local file upload, local file download, and delete |
runtime | @hot-updater/server when createHotUpdater handles update checks in a server, serverless function, or edge runtime | Client HTTP(S) bundle URLs and direct text reads for metadata |
Use the helper that matches where the plugin will run:
| Helper | Use when |
|---|---|
createNodeStoragePlugin | The plugin is configured in hot-updater.config.ts and runs from CLI/Node workflows such as hot-updater deploy. |
createRuntimeStoragePlugin | The plugin is passed to createHotUpdater({ storages }) from @hot-updater/server for update-check runtime reads. |
createUniversalStoragePlugin | The 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:
| Item | Example | Notes |
|---|---|---|
name | customStorage | Used in diagnostics. Keep it stable and human-readable. |
supportedProtocol | custom-s3 | Must match every storageUri the plugin returns. |
| Storage URI shape | custom-s3://bucket/releases/bundle.zip | Include enough information to locate the object later. |
| Upload key policy | basePath / bundleId / filename | Use createStorageKeyBuilder unless the provider needs custom behavior. |
| Runtime URL policy | Signed URL, public CDN URL, or worker route | runtime.getDownloadUrl must return HTTP(S). |
| Text read policy | SDK call, binding read, or authenticated API | runtime.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(...)inhot-updater.config.ts, where CLI commands such ashot-updater deployneed thenodeprofile.storages: [customStorage(...)]increateHotUpdaterfrom@hot-updater/server, where update checks need theruntimeprofile.
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.
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:
uploadreturns astorageUriusing the configured protocol and bucket.downloadFilecreates the destination file.deleterejects storage URIs from a different bucket or namespace.getDownloadUrlreturns an HTTP(S) URL.readTextreturns text for an existing metadata object andnullfor 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
storageUribefore reading, downloading, or deleting. - Return only HTTP(S) URLs from
runtime.getDownloadUrl. - Keep
runtime.readTexton direct provider reads for metadata. - Prefer short-lived signed URLs when storage objects are private.