Static Asset Component Reference
Every binding, endpoint, type, and internal mechanism of StaticAssetComponent. For task-oriented walkthroughs, see Usage & Examples.
Files:
packages/core/src/components/static-asset/component.tspackages/core/src/components/static-asset/common/types.tspackages/core/src/components/static-asset/common/constants.tspackages/core/src/components/static-asset/common/keys.tspackages/core/src/components/static-asset/controller/factory.tspackages/core/src/components/static-asset/controller/base.definition.tspackages/core/src/components/static-asset/models/base.model.tspackages/core/src/components/static-asset/repositories/base.repository.tspackages/helpers/src/modules/storage/base.tspackages/helpers/src/modules/storage/disk/helper.tspackages/helpers/src/modules/storage/minio/helper.tspackages/helpers/src/modules/storage/bun-s3/helper.tspackages/helpers/src/utilities/request.utility.ts
Quick reference
| Item | Value |
|---|---|
| Package | @venizia/ignis (core component) + @venizia/ignis-helpers (storage helpers) |
| Component class | StaticAssetComponent |
| Import subpath | @venizia/ignis/static-asset - not on the root barrel |
| Storage helpers | DiskHelper, MinioHelper (@venizia/ignis-helpers/minio), BunS3Helper (@venizia/ignis-helpers/bun-s3) |
| Runtimes | Both - BunS3Helper specifically requires Bun (imports Bun's native S3Client) |
| Optional feature | MetaLink - Postgres-backed upload tracking via BaseMetaLinkModel/BaseMetaLinkRepository |
Import paths
// Core - subpath import only
import {
StaticAssetComponent,
StaticAssetComponentBindingKeys,
StaticAssetStorageTypes,
AssetControllerFactory,
BaseMetaLinkModel,
BaseMetaLinkRepository,
} from '@venizia/ignis/static-asset';
import type {
TStaticAssetsComponentOptions,
TStaticAssetExtraOptions,
TMetaLinkConfig,
TStaticAssetStorageType,
IAssetControllerOptions,
} from '@venizia/ignis/static-asset';
// Helpers - main entry + storage-backend subpaths
import { DiskHelper } from '@venizia/ignis-helpers';
import { MinioHelper } from '@venizia/ignis-helpers/minio';
import { BunS3Helper } from '@venizia/ignis-helpers/bun-s3';Binding keys
| Key | Constant | Type | Required | Default |
|---|---|---|---|---|
@app/static-asset-component/options | StaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONS | TStaticAssetsComponentOptions | Yes | {} |
NOTE
StaticAssetComponent's constructor binds an empty {} default for this key. binding() iterates Object.entries(componentOptions) - an empty object produces zero controllers, no error. Bind your configuration in preConfigure() before this.component(StaticAssetComponent).
TStaticAssetsComponentOptions
type TStaticAssetsComponentOptions = {
[key: string]: {
controller: {
name: string;
basePath: string;
isStrict?: boolean;
routes?: {
getBuckets?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
getBucketByName?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
createBucket?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
deleteBucket?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
upload?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
listObjects?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
deleteObject?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
getObjectByName?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
downloadObjectByName?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
recreateMetaLink?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
};
};
extra?: TStaticAssetExtraOptions;
} & (
| { storage: typeof StaticAssetStorageTypes.BUN_S3; helper: BunS3Helper }
| { storage: typeof StaticAssetStorageTypes.DISK; helper: DiskHelper }
| { storage: typeof StaticAssetStorageTypes.MINIO; helper: MinioHelper }
) &
({ useMetaLink?: false | undefined } | { useMetaLink: true; metaLink: TMetaLinkConfig });
};| Field | Type | Default | Description |
|---|---|---|---|
controller.name | string | - | Class name given to the generated controller (via Object.defineProperty) |
controller.basePath | string | - | Mount path, e.g. '/assets' |
controller.isStrict | boolean | true | Passed through to BaseRestController's strict routing mode |
controller.routes | object | undefined | Per-route overrides - see Per-route overrides |
storage | 'disk' | 'minio' | 'bun-s3' | - | Selects which helper type is required (discriminated union) |
helper | DiskHelper | MinioHelper | BunS3Helper | - | Storage backend instance matching storage |
extra | TStaticAssetExtraOptions | undefined | Multipart parsing mode, name/link normalization, max folder depth |
useMetaLink | boolean | false | Enables the PUT .../meta-links/:objectName route and DB tracking on upload/delete |
metaLink | TMetaLinkConfig | - | Required when useMetaLink: true; ignored otherwise |
Per-route overrides
Each key accepts a Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>> - typically authenticate, authorize, path, and middleware. It is shallow-merged onto the base definition: { ...StaticAssetDefinitions.UPLOAD, ...routes?.upload }.
| Route key | HTTP Method | Base Path |
|---|---|---|
getBuckets | GET | /buckets |
getBucketByName | GET | /buckets/{bucketName} |
createBucket | POST | /buckets/{bucketName} |
deleteBucket | DELETE | /buckets/{bucketName} |
upload | POST | /buckets/{bucketName}/upload |
listObjects | GET | /buckets/{bucketName}/objects |
getObjectByName | GET | /buckets/{bucketName}/objects/{objectName} |
downloadObjectByName | GET | /buckets/{bucketName}/download/{objectName} |
deleteObject | DELETE | /buckets/{bucketName}/objects/{objectName} |
recreateMetaLink | PUT | /buckets/{bucketName}/meta-links/{objectName} - only registered when useMetaLink: true |
TStaticAssetExtraOptions
type TStaticAssetExtraOptions = {
parseMultipartBody?: {
storage?: 'memory' | 'disk';
uploadDir?: string;
};
normalizeNameFn?: (opts: { originalName: string; folderPath?: string }) => string;
normalizeLinkFn?: (opts: { bucketName: string; normalizeName: string }) => string;
/** Maximum folder nesting depth allowed in object paths. Default: 2 */
maxFolderDepth?: number;
[key: string]: any;
};NOTE
normalizeNameFn receives both originalName and folderPath - the second lets a custom implementation decide how to fold the target folder into the stored name. Omit folderPath handling and nested uploads flatten into the bucket root.
| Field | Default | Notes |
|---|---|---|
parseMultipartBody.storage | 'memory' | 'disk' spools to uploadDir, then the controller reads the file back with readFileSync before handing it to the storage helper |
parseMultipartBody.uploadDir | './uploads' | Created with fs.mkdirSync({ recursive: true }) if missing |
normalizeNameFn | BaseStorageHelper's internal lowercase + _-for-space normalizer | Runs before the file is written; its output is re-validated with isValidPath() |
normalizeLinkFn | Component-generated - see below | Runs after the write to build the returned link |
maxFolderDepth | BaseStorageHelper.DEFAULT_MAX_FOLDER_DEPTH (2) | Folder segments only - the filename itself does not count against this limit |
Default normalizeLinkFn
StaticAssetComponent.binding() always supplies a normalizeLinkFn to the factory - your own extra.normalizeLinkFn if set, otherwise this default:
(opts: { bucketName: string; normalizeName: string }) => {
const encodedPath = encodeURIComponent(opts.normalizeName);
return `${controller.basePath}/buckets/${opts.bucketName}/objects/${encodedPath}`;
};This is why every generated link points back at the objects/{objectName} stream route by default, regardless of storage backend. BaseStorageHelper's own backend-specific normalizeObjectLink() (used when a helper's upload() is called directly, outside the component) is never reached through StaticAssetComponent - the component's default always takes priority when no normalizeLinkFn is set.
Storage types
class StaticAssetStorageTypes {
static readonly DISK = 'disk';
static readonly MINIO = 'minio';
static readonly BUN_S3 = 'bun-s3';
static readonly SCHEME_SET = new Set([this.DISK, this.MINIO, this.BUN_S3]);
static isValid(orgType: string): boolean {
return this.SCHEME_SET.has(orgType);
}
}
type TStaticAssetStorageType = TConstValue<typeof StaticAssetStorageTypes>;
// 'disk' | 'minio' | 'bun-s3'| Type | Constant | Helper | Requires |
|---|---|---|---|
'disk' | StaticAssetStorageTypes.DISK | DiskHelper | Local filesystem write access |
'minio' | StaticAssetStorageTypes.MINIO | MinioHelper | A MinIO or S3-compatible endpoint |
'bun-s3' | StaticAssetStorageTypes.BUN_S3 | BunS3Helper | Bun runtime (imports Bun's native S3Client) |
Storage helpers
IStorageHelper interface
Every backend implements this contract; BaseStorageHelper (abstract) implements the shared parts (isValidName, isValidPath, upload, getMimeType, getFileType) and leaves the rest abstract.
interface IStorageHelper {
isValidName(name: string): boolean;
isValidPath(pathStr: string, opts?: { maxDepth?: number }): boolean;
isBucketExists(opts: { name: string }): Promise<boolean>;
getBuckets(): Promise<IBucketInfo[]>;
getBucket(opts: { name: string }): Promise<IBucketInfo | null>;
createBucket(opts: { name: string }): Promise<IBucketInfo | null>;
removeBucket(opts: { name: string }): Promise<boolean>;
upload(opts: {
bucket: string;
files: IUploadFile[];
normalizeNameFn?: (opts: { originalName: string; folderPath?: string }) => string;
normalizeLinkFn?: (opts: { bucketName: string; normalizeName: string }) => string;
maxFolderDepth?: number;
}): Promise<IUploadResult[]>;
getFile(opts: { bucket: string; name: string; options?: any }): Promise<Readable>;
getStat(opts: { bucket: string; name: string }): Promise<IFileStat>;
removeObject(opts: { bucket: string; name: string }): Promise<void>;
removeObjects(opts: { bucket: string; names: string[] }): Promise<void>;
listObjects(opts: IListObjectsOptions): Promise<IObjectInfo[]>;
getFileType(opts: { mimeType: string }): string;
}IStorageHelper (interface)
|
BaseStorageHelper (abstract - implements isValidName/isValidPath/upload/getMimeType/getFileType)
|
+-- DiskHelper (local filesystem)
+-- MinioHelper (S3-compatible)
+-- BunS3Helper (Bun-native S3, Bun only)Supporting types
interface IUploadFile {
originalName: string;
mimetype: string;
buffer: Buffer;
size: number;
encoding?: string;
folderPath?: string;
[key: string | symbol]: any;
}
interface IUploadResult {
bucketName: string;
objectName: string;
link: string;
metaLink?: any;
metaLinkError?: any;
}
interface IFileStat {
size: number;
metadata: Record<string, any>;
lastModified?: Date;
etag?: string;
versionId?: string;
}
interface IBucketInfo {
name: string;
creationDate: Date;
}
interface IObjectInfo {
name?: string;
size?: number;
lastModified?: Date;
etag?: string;
prefix?: string;
}
interface IListObjectsOptions {
bucket: string;
prefix?: string;
useRecursive?: boolean;
maxKeys?: number;
}Name and path validation (BaseStorageHelper)
isValidName(name) rejects, in order: non-string input, empty string, ..///\ (path traversal), a leading . (hidden file), any of ;, \|, &, $, `, <, >, {, }, [, ], !, # (shell metacharacters), \n/\r/\0 (control characters), length over 255, and whitespace-only input.
isValidPath(pathStr, { maxDepth }) trims leading/trailing slashes, rejects an empty result, rejects double slashes (empty segments), computes folderDepth = segments.length - 1 and rejects it exceeding maxDepth (default BaseStorageHelper.DEFAULT_MAX_FOLDER_DEPTH = 2), validates every segment through isValidName(), and rejects a normalized length over 1024 characters.
DiskHelper
interface IDiskHelperOptions {
basePath: string; // Base directory for storage
scope?: string; // Logger scope, default: 'DiskHelper'
identifier?: string; // Helper identifier, default: 'DiskHelper'
}Creates basePath with fs.mkdirSync({ recursive: true }) in the constructor if it does not already exist. Buckets map to subdirectories; objects map to files inside them.
const diskHelper = new DiskHelper({ basePath: './app_data/storage' });MinioHelper
interface IMinioHelperOptions extends ClientOptions { // minio's own SDK options, plus:
scope?: string;
identifier?: string;
}ClientOptions comes straight from the minio package - endPoint, port, useSSL, accessKey, secretKey, and the rest of the MinIO client's own configuration surface.
const minioHelper = new MinioHelper({
endPoint: 'minio.example.com',
port: 9000,
useSSL: true,
accessKey: process.env.MINIO_ACCESS_KEY,
secretKey: process.env.MINIO_SECRET_KEY,
});BunS3Helper
interface IBunS3HelperOptions {
accessKey: string;
secretKey: string;
endpoint: string;
region?: string; // Default: 'us-east-1'
sessionToken?: string;
scope?: string;
identifier?: string;
}Wraps Bun's native S3Client, imported from the bun builtin module - it only resolves under the Bun runtime, which is why it is exported from the separate @venizia/ignis-helpers/bun-s3 subpath rather than the main entry point.
import { BunS3Helper } from '@venizia/ignis-helpers/bun-s3';
const bunS3Helper = new BunS3Helper({
accessKey: process.env.S3_ACCESS_KEY,
secretKey: process.env.S3_SECRET_KEY,
endpoint: 'https://s3.us-east-1.amazonaws.com',
});Controller factory
AssetControllerFactory.defineAssetController(opts: IAssetControllerOptions) builds one controller class per call:
interface IAssetControllerOptions {
controller: TStaticAssetsComponentOptions[string]['controller'];
storage: TStaticAssetStorageType;
helper: IStorageHelper;
useMetaLink?: boolean;
metaLink?: TMetaLinkConfig;
options?: TStaticAssetExtraOptions;
}- Creates a class extending
BaseRestController, decorated@controller({ path: basePath }). - Renames it via
Object.defineProperty(GeneratedStaticAssetController, 'name', { value: name, configurable: true })so logs and DI bindings show your configuredcontroller.name, not a generic factory name. - Binds every route in
binding()withthis.bindRoute({ configs }).to({ handler }), spread-merging each base definition with itsroutes?.<key>override. - Registers
recreateMetaLinkonly whenuseMetaLink && metaLinkare both set. StaticAssetComponent.binding()registers the resulting class withthis.application.controller(...).
StaticAssetComponent.binding()
| iterates componentOptions
AssetControllerFactory.defineAssetController({ controller, storage, helper, ... })
| creates
@controller({ path: basePath })
class GeneratedStaticAssetController extends BaseRestController { ... }
| registered via
this.application.controller(GeneratedStaticAssetController)MultipartBodySchema
The Zod schema validating the upload request body:
const MultipartBodySchema = z.object({
files: z.union([z.instanceof(File), z.array(z.instanceof(File))]).openapi({
type: 'array',
items: { type: 'string', format: 'binary' },
}),
});Endpoint reference
| Method | Path | Notes |
|---|---|---|
GET | /buckets | No params. Returns IBucketInfo[] |
GET | /buckets/{bucketName} | Returns IBucketInfo | null |
POST | /buckets/{bucketName} | Returns IBucketInfo | null (null if creation failed, e.g. already exists) |
DELETE | /buckets/{bucketName} | Returns { isDeleted: boolean } |
POST | /buckets/{bucketName}/upload | multipart/form-data body; query: principalType?, principalId?, variant?, folderPath?. Returns IUploadResult[] |
GET | /buckets/{bucketName}/objects | Query: prefix?, recursive? ('true' string only), maxKeys? (positive integer string). Returns IObjectInfo[] |
GET | /buckets/{bucketName}/objects/{objectName} | Streams the file inline. objectName is a single percent-encoded segment |
GET | /buckets/{bucketName}/download/{objectName} | Streams the file with Content-Disposition: attachment |
DELETE | /buckets/{bucketName}/objects/{objectName} | Returns { success: boolean } |
PUT | /buckets/{bucketName}/meta-links/{objectName} | Only registered when useMetaLink: true. Returns { success: boolean, metaLink } |
Upload validation order
bucketNamevalidated withisValidName()-400 "Invalid bucket name"on failure.- If
folderPathis present: trimmed of leading/trailing slashes,400 "Invalid folder path"if empty after trimming; segment count checked againstmaxFolderDepth,400 "Folder path exceeds max depth of {n}"if over; each segment checked withisValidName(),400 "Invalid folder path segment: {segment}"if any fails. multipart/form-dataparsed viaparseMultipartBody().- Each file's effective buffer (direct
buffer, orreadFileSync(file.path)whenstorage: 'disk'was used) checked non-empty -400 "Empty file content | name: {originalName}"if empty. helper.upload()runs the storage-helper-level checks below.- Spool files written by
storage: 'disk'parsing are removed in afinallyblock viarmSync({ force: true }), regardless of success or failure; removal errors are logged, never thrown.
Storage-helper-level upload checks (BaseStorageHelper.upload)
These run inside helper.upload(), independent of and in addition to the controller's own checks above - reachable even when a caller uses the storage helper directly:
| Check | Error message | Default status |
|---|---|---|
Bucket does not exist (isBucketExists() false) | [upload] Bucket does not exist | name: {bucket} | 400 |
originalName fails isValidName() | [upload] Invalid original file name | 400 |
folderPath exceeds depth or fails isValidPath() | [upload] Invalid folder path | 400 |
size is undefined, null, or negative | [upload] Invalid file size | size: {size} | 400 |
Normalized name (post normalizeNameFn) fails isValidPath() | [upload] Invalid normalized object name | name: {name} | 400 |
getError() defaults statusCode to 400 when the caller does not pass one explicitly - every message above is thrown without an explicit status, so all resolve to 400.
Header sanitization
const WHITELIST_HEADERS = [
'content-type',
'content-encoding',
'cache-control',
'etag',
'last-modified',
] as const;These correspond to HTTP.Headers.CONTENT_TYPE, CONTENT_ENCODING, CACHE_CONTROL, ETAG, and LAST_MODIFIED from @venizia/ignis-helpers. When streaming a file (both objects/{objectName} and download/{objectName}), the controller copies only these keys from the storage metadata onto the response; every other metadata header is dropped. Each forwarded value is sanitized with String(value).replace(/[\r\n]/g, '') before being set, to prevent HTTP header injection.
All streaming responses also set:
X-Content-Type-Options: nosniff
Content-Type: <from metadata, or application/octet-stream as fallback>
Content-Length: <file size in bytes>
Content-Disposition: attachment; filename="..." (download endpoint only, via createContentDispositionHeader())Object name decoding
Hono percent-decodes a path param before the handler reads it. The controller's readObjectName() is therefore a deliberate no-op - it does not run a second decodeURIComponent():
report_100%.pdfis a legal object name. Its link is.../objects/report_100%25.pdf; Hono hands the handler backreport_100%.pdf. A second decode would hit the invalid escape%.pand throw, making the object permanently unfetchable and undeletable.- An object named
a%2Fb.pngwould decode twice intoa/b.png- a different object than the one requested.
isValidName()/isValidPath() still run on the singly-decoded value, so a traversal payload is rejected exactly as before.
TMetaLinkConfig
type TMetaLinkConfig<Schema extends TMetaLinkSchema = TMetaLinkSchema> = {
model: typeof BaseRelationalEntity<Schema>;
repository: DefaultRelationalRepository<Schema>;
createMetaLink?: (opts: {
uploadResult: IUploadResult;
fileStat: IFileStat;
query: TUploadQuery;
}) => ValueOrPromise<{ count: number; data: Schema }>;
};BaseRelationalEntity and DefaultRelationalRepository are the canonical class names (packages/core/src/connectors/postgres/); BasePostgresEntity and DefaultCRUDRepository are re-exported aliases of the same classes.
MetaLink SQL schema
Table: MetaLink
| Field | Type | Nullable | Default | Description |
|---|---|---|---|---|
id | TEXT | No | - | Primary key |
created_at | TIMESTAMPTZ | No | NOW() | Row creation time |
modified_at | TIMESTAMPTZ | No | NOW() | Row last-update time |
bucket_name | TEXT | No | - | Storage bucket name |
object_name | TEXT | No | - | File object name (may include folder segments) |
link | TEXT | No | - | Access URL to the file |
mimetype | TEXT | No | - | File MIME type |
size | INTEGER | No | - | File size in bytes |
etag | TEXT | Yes | - | Entity tag for versioning |
metadata | JSONB | Yes | - | Additional file metadata |
storage_type | TEXT | No | - | 'disk', 'minio', or 'bun-s3' |
is_synced | BOOLEAN | No | false | Set true on every upload and every meta-links sync |
variant | TEXT | Yes | - | Upload variant tag (e.g. 'thumbnail', 'original') |
principal_type | TEXT | Yes | - | Associated principal type |
principal_id | TEXT | Yes | - | Associated principal ID, always stored as a string |
Indexes: bucket_name, object_name, storage_type, is_synced.
@model({ type: 'entity', skipMigrate: true }) on BaseMetaLinkModel means IGNIS's schema migration skips this table - it must be created manually, once, per database.
MetaLink lifecycle
- On upload: creates one MetaLink row per uploaded file after fetching fresh stats via
helper.getStat(). UsesmetaLink.createMetaLink()when provided, otherwise a default insert covering every standard field.principalType,principalId, andvariantare taken from the upload's query parameters. If the insert throws, the upload still succeeds and the file's entry in the response getsmetaLink: nullplus ametaLinkErrorstring; the error is also logged. - On delete: storage delete happens first and is awaited; the MetaLink row delete (
deleteAll({ where: { bucketName, objectName } })) is fired without awaiting it. The HTTP response returns as soon as the storage delete resolves - the database delete may still be in flight. Errors there are logged, never surfaced to the client. - On sync (
PUT meta-links/:objectName): looks up an existing row bybucketName+objectName. If found,updateById()then re-fetches withfindById(). If not found,create(). Either path always setsisSynced: trueand returns{ success: true, metaLink }.
Component lifecycle
binding()readsSTATIC_ASSET_COMPONENT_OPTIONSfrom the DI container.- Iterates each key in the options object.
- For each entry, builds a
normalizeLinkFndefault if the caller did not supply one (see Default normalizeLinkFn). - Calls
AssetControllerFactory.defineAssetController()and registers the result withthis.application.controller(). - Logs the storage key, storage type, and whether MetaLink is enabled for each registered backend.
StaticAssetComponent itself performs no eager configuration validation beyond the options type - a missing metaLink when useMetaLink: true is caught at compile time by the discriminated union, not at binding() runtime.
See also
- Overview - quick start, imports, and common configuration tasks
- Usage & Examples - task-oriented walkthroughs for every endpoint and MetaLink setup
- Error Reference - name validation rules and troubleshooting