Storage

Custom adapter

Write your own VitNode storage adapter - implement StorageApiPlugin's upload, delete and getUrl and store uploaded files anywhere.

A storage adapter is a plain factory that returns three functions. VitNode has already validated the file, re-encoded the image and built a collision-free key before it calls you - your job is to put bytes somewhere and be able to name their URL afterwards.

Quick start

The whole contract is one interface, and there is nothing to install to implement it:

packages/vitnode/src/api/models/storage.ts
export interface StorageApiPlugin {
  delete: (key: string) => Promise<void>
  getUrl: (key: string) => string
  static?: StorageStaticConfig
  upload: (args: StorageUploadArgs) => Promise<StorageUploadResult>
}

Return an object of that shape from a factory, set it as storage.adapter in your API config, and VitNode stores files through it. The working skeleton below is about 60 lines.

The three methods

MethodGetsMust returnRules
upload({ key, body, contentType })A Node Buffer, the media type, and a pre-built key{ key, url }Store body at that exact key. The key is written to the database as-is
delete(key)The key from the core_files rowPromise<void>An object that is already gone is a success, not an error
getUrl(key)The same keyA public URL, synchronouslyBuild a string. No network, no await - it runs once per row in a listing

StorageUploadArgs and StorageUploadResult are exported from the same module, so you never have to restate them:

packages/vitnode/src/api/models/storage.ts
export interface StorageUploadArgs {
  body: Buffer
  contentType?: string
  key: string
}

export interface StorageUploadResult {
  key: string
  url: string
}

Write one

Write the adapter

A complete adapter, against any object store that answers PUT and DELETE and serves the objects back from a public base URL. Both official cloud adapters default their arguments to "" and check them lazily, so a missing environment variable fails on the first upload with a sentence rather than at import time with a stack trace.

src/utils/storage/http-storage.ts
import type {
  StorageApiPlugin,
  StorageUploadArgs,
  StorageUploadResult,
} from '@vitnode/core/api/models/storage'

export const HttpStorageAdapter = ({
  apiKey = '',
  endpoint = '',
  publicUrl = '',
}: {
  apiKey: string | undefined
  endpoint: string | undefined
  publicUrl: string | undefined
}): StorageApiPlugin => {
  const requireConfig = () => {
    if (!(apiKey && endpoint && publicUrl)) {
      throw new Error('Missing HTTP storage configuration')
    }

    return { apiKey, endpoint: endpoint.replace(/\/$/, '') }
  }

  const getUrl = (key: string): string =>
    `${publicUrl.replace(/\/$/, '')}/${key}`

  return {
    getUrl,
    delete: async (key: string): Promise<void> => {
      const { apiKey, endpoint } = requireConfig()

      const res = await fetch(`${endpoint}/${key}`, {
        method: 'DELETE',
        headers: { Authorization: `Bearer ${apiKey}` },
      })
      if (!res.ok && res.status !== 404) {
        throw new Error(`Storage delete failed with ${res.status}`)
      }
    },
    upload: async ({
      body,
      contentType,
      key,
    }: StorageUploadArgs): Promise<StorageUploadResult> => {
      const { apiKey, endpoint } = requireConfig()

      const res = await fetch(`${endpoint}/${key}`, {
        method: 'PUT',
        headers: {
          Authorization: `Bearer ${apiKey}`,
          'Content-Type': contentType ?? 'application/octet-stream',
        },
        body: new Uint8Array(body),
      })
      if (!res.ok) {
        throw new Error(`Storage upload failed with ${res.status}`)
      }

      return { key, url: getUrl(key) }
    },
  }
}

Two details worth copying rather than reinventing. delete treats a 404 as done, because deleteFile has already removed the database row by the time it runs - throwing there turns a finished delete into a failed request. And upload returns the key it was handed, never a rewritten one.

Register it

An adapter needs no registry, no manifest and no plugin entry. It is a value on the API config:

src/vitnode.api.config.ts
import { buildApiConfig } from '@vitnode/core/vitnode.config'

import { HttpStorageAdapter } from './utils/storage/http-storage'

export const vitNodeApiConfig = buildApiConfig({
  storage: {
    adapter: HttpStorageAdapter({
      apiKey: process.env.STORAGE_API_KEY,
      endpoint: process.env.STORAGE_ENDPOINT,
      publicUrl: process.env.STORAGE_PUBLIC_URL,
    }),
  },
})

Verify it round-trips

Open AdminCP → Core → System → Integrations (/admin/core/system/integrations) and click Test storage on the Storage card. It uploads an image through your adapter, then the row shows up in AdminCP → Core → System → Files with a working thumbnail - which is getUrl proving itself, since the browser loads that URL directly.

If the thumbnail is broken but the upload succeeded, upload and getUrl disagree about the URL. If the upload itself failed, the API log has your own error message in it.

Keys are pre-built for you

The framework builds month_{month}_{year}/{folder}/<uuid>.<ext> before calling upload, checks every folder segment against the traversal guard, and stores that key on the core_files row. So an adapter stores body at the key it was handed and never invents path logic - see storage keys.

The optional static descriptor

Disk-backed adapters can expose a fourth field, and only they should:

export interface StorageStaticConfig {
  mountPath: string
  root: string
  stripPrefix: string
}

It is not read by the storage layer at all - the app that boots the API reads it to mount Hono's serveStatic for the stored files, which is how the Local adapter serves what it writes. A cloud adapter omits it, and the mount is then skipped.

FieldThe Local adapter's value
mountPathpublicPath with a leading /api stripped, plus /*
root./public/uploads
stripPrefixpublicPath, e.g. /api/uploads

Gotchas

A thrown error becomes a 500

Your errors are not HTTPExceptions, so the API's error handler answers 500

  • with your message in the body during development and a bare "Internal Server Error" in production, where only the log has the reason. That is the same deal the official adapters get. Throw an HTTPException from hono/http-exception instead when the person uploading can act on the cause - a quota, say, rather than a broken token.

getUrl runs once per row

The AdminCP Files table calls it for every file on the page, and it is declared synchronous, so a signed URL that needs a round trip does not fit here. Put a CDN or a public base URL in front of the store and build a string.

Never rewrite the key

Returning a different key than you were given stores that value on the row - and every later read, download and delete uses it. If your provider mangles the path (a leading slash, a normalised case), return the key as the provider will accept it back, not as you wish it were.

Sizes and dimensions are already decided

body.length is what lands in core_files.size, and the image pipeline has already run. An adapter that compresses further would make the database disagree with the object, so leave the bytes alone.

Publish it

If the adapter is useful to more than one installation, publish it as a package. Both official adapters are small enough to read in one sitting and are the best starting template:

Keep @vitnode/core a dev dependency (you only import types from it) and ship your provider's SDK as a real dependency, the way both of those do.

Next