@norialabs/storage
v0.1.1
Published
Configurable S3 and R2 storage client for Node.js services.
Maintainers
Readme
@norialabs/storage
Configurable object storage client for S3-compatible providers, with first-class support for AWS S3 and Cloudflare R2.
Node >=20 is required.
Install
npm install @norialabs/storageWhat This Package Gives You
- one API for both AWS S3 and Cloudflare R2
- direct object operations and presigned URL generation
- public URL derivation with sensible defaults and override hooks
- constructor-level defaults for metadata, tags, content headers, and TTLs
- raw AWS command overrides when you need lower-level S3 options
- injectable S3 client and presigner hooks for advanced runtime control
Exports
import StorageClient, {
DEFAULT_DOWNLOAD_EXPIRES_IN,
DEFAULT_R2_REGION,
DEFAULT_S3_REGION,
DEFAULT_UPLOAD_EXPIRES_IN,
MAX_PRESIGN_EXPIRES_IN,
StorageError,
createStorageClient,
joinStorageKey,
} from "@norialabs/storage";StorageClientis the main class and the default exportcreateStorageClient(options)is a convenience wrapper aroundnew StorageClient(options)joinStorageKey(...parts)normalizes storage keys the same way the client doesDEFAULT_S3_REGION,DEFAULT_R2_REGION,DEFAULT_UPLOAD_EXPIRES_IN,DEFAULT_DOWNLOAD_EXPIRES_IN, andMAX_PRESIGN_EXPIRES_INexpose the package defaultsStorageErroris the package error type for wrapped operation failures
Useful exported types include:
StorageClientOptionsStorageProviderStorageUrlStyleStorageKeyStorageMetadataStorageTagsStorageObjectTargetStorageObjectDescriptorPutObjectInputandPutObjectResultHeadObjectInputandHeadObjectResultDeleteObjectInputandDeleteObjectResultCreatePresignedUploadUrlInputCreatePresignedDownloadUrlInputCreatePublicUrlInputPresignedRequestResolvedStoragePublicUrlInputStorageOperationStorageOperationContextStorageCommandClientStoragePresignHandler
Quick Start
import { StorageClient } from "@norialabs/storage";
const storage = new StorageClient({
bucket: "documents",
region: "eu-west-1",
keyPrefix: "tenant-a",
publicBaseUrl: "https://cdn.example.com",
});
await storage.putObject({
key: ["invoices", "march-2026.pdf"],
body: Buffer.from("hello"),
contentType: "application/pdf",
metadata: {
source: "admin",
},
});
const upload = await storage.createPresignedUploadUrl({
key: ["uploads", "avatar.png"],
contentType: "image/png",
});Defaults And Provider Behavior
| Setting | S3 | R2 |
| --- | --- | --- |
| provider default | "s3" | n/a |
| region default | "us-east-1" | "auto" |
| urlStyle default | "virtual-hosted" | "path" |
| derived endpoint | none | https://<accountId>.r2.cloudflarestorage.com when accountId is set |
Other defaults:
defaultUploadExpiresIndefaults to900secondsdefaultDownloadExpiresIndefaults to3600seconds- any presign TTL must be a positive integer and may not exceed
604800seconds
Resolution rules:
urlStylewins overforcePathStyle- if
urlStyleis not supplied,forcePathStyle: truebecomes"path"andforcePathStyle: falsebecomes"virtual-hosted" - if neither
urlStylenorforcePathStyleis supplied, the provider default is used - explicit
endpointwins over derived R2 endpoint generation
Credentials, Clients, And AWS Overrides
The storage client exposes the same kind of credential override surface that @norialabs/logger exposes for CloudWatch.
Explicit S3 credentials
import { StorageClient } from "@norialabs/storage";
const storage = new StorageClient({
provider: "s3",
bucket: "public-assets",
region: "eu-west-1",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
sessionToken: process.env.AWS_SESSION_TOKEN,
},
});Explicit R2 credentials
import { StorageClient } from "@norialabs/storage";
const storage = new StorageClient({
provider: "r2",
bucket: "attachments",
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
publicBaseUrl: "https://files.example.com",
});Default AWS SDK credential chain
If credentials is omitted, the internally constructed S3Client uses the AWS SDK default credential chain as-is.
That means the package works with the usual AWS SDK sources:
- environment variables
- shared config and credentials files
- IAM roles
- web identity
- any other source the AWS SDK would normally resolve
Custom client
You can provide your own client if you want full control over transport behavior.
import { S3Client } from "@aws-sdk/client-s3";
import { StorageClient } from "@norialabs/storage";
const rawClient = new S3Client({
region: "eu-west-1",
maxAttempts: 5,
});
const storage = new StorageClient({
bucket: "documents",
region: "eu-west-1",
client: rawClient,
});Important behavior:
- if
clientis supplied, the package does not construct its ownS3Client credentialsands3ClientConfigonly affect the internally createdS3Client; they do not reconfigure a customclient- top-level storage settings such as
provider,region,endpoint,publicBaseUrl,accountId,keyPrefix, andurlStylestill control key resolution, public URL derivation, and normalized results
Custom presignUrl
By default, presigned URLs use @aws-sdk/s3-request-presigner's getSignedUrl.
If you supply a custom client that is not a real S3Client, and you still want presigned URLs, provide a matching presignUrl implementation too.
const storage = new StorageClient({
bucket: "assets",
client: myCustomClient,
presignUrl: async (_client, command, { expiresIn }) => {
return signSomeOtherWay(command, expiresIn);
},
});s3ClientConfig
Use s3ClientConfig to pass extra AWS SDK S3Client options without giving up the higher-level storage API.
const storage = new StorageClient({
bucket: "documents",
region: "eu-west-1",
s3ClientConfig: {
maxAttempts: 4,
retryMode: "standard",
},
});Notes:
s3ClientConfigis merged first- top-level
region,endpoint,credentials, and resolvedforcePathStylewin over the same settings s3ClientConfigexists for extra client tuning, not for replacing the package's storage-level configuration model
Constructor Reference
Core Provider Options
| Option | Required | Description |
| --- | --- | --- |
| bucket | yes | Default bucket for all operations. Must be a non-empty string. |
| provider | no | "s3" or "r2". Defaults to "s3". |
| region | no | Region for URL generation and the internally created S3Client. Defaults to "us-east-1" for S3 and "auto" for R2. |
| endpoint | no | Explicit S3-compatible endpoint. If omitted for R2 and accountId is set, the package derives https://<accountId>.r2.cloudflarestorage.com. |
| accountId | no | R2 account ID used to derive the default R2 endpoint when endpoint is omitted. Ignored for S3 public URL derivation. |
| credentials | no | Passed to the internally created S3Client as S3ClientConfig["credentials"]. Supports static credentials or a credential provider function. |
| publicBaseUrl | no | Base URL used first when deriving public URLs. Useful for CDNs or custom public domains. Trailing slashes are normalized away. |
| keyPrefix | no | Prefix added to every resolved key before the optional resolveKey hook runs. Supports string or string-array input. |
| urlStyle | no | "path" or "virtual-hosted". Controls endpoint and public URL formatting. Takes precedence over forcePathStyle. |
| forcePathStyle | no | Compatibility alias for the AWS SDK path-style setting. Only used when urlStyle is not provided. |
Default Object Options
| Option | Required | Description |
| --- | --- | --- |
| defaultMetadata | no | Default metadata merged into putObject and createPresignedUploadUrl. Per-call metadata wins on conflicts. |
| defaultTags | no | Default object tags merged into putObject and createPresignedUploadUrl. Per-call tags win on conflicts. |
| defaultContentType | no | Default ContentType for uploads when a call does not set one directly. |
| defaultCacheControl | no | Default CacheControl for uploads when a call does not set one directly. |
| defaultContentDisposition | no | Default ContentDisposition for uploads when a call does not set one directly. |
| defaultContentEncoding | no | Default ContentEncoding for uploads when a call does not set one directly. |
| defaultContentLanguage | no | Default ContentLanguage for uploads when a call does not set one directly. |
| defaultUploadExpiresIn | no | Default TTL, in seconds, for createPresignedUploadUrl. Defaults to 900. Must be 1..604800. |
| defaultDownloadExpiresIn | no | Default TTL, in seconds, for createPresignedDownloadUrl. Defaults to 3600. Must be 1..604800. |
Extension Points
| Option | Required | Description |
| --- | --- | --- |
| client | no | Custom command client with a send(command) method. If supplied, it replaces the internally constructed S3Client. |
| presignUrl | no | Custom presign handler. Receives the resolved client, the AWS command, and { expiresIn }. |
| s3ClientConfig | no | Extra AWS SDK S3Client options. Cannot override region, endpoint, credentials, or forcePathStyle; the top-level storage options own those. |
| resolveKey | no | Hook that receives the normalized key after keyPrefix has been applied. Return the final key to send to storage. Receives { operation, bucket, provider }. |
| buildPublicUrl | no | Hook that receives the fully resolved public URL input and returns the final public URL string. This is the highest-priority public URL override. |
Key, Bucket, And URL Resolution
Key normalization
Every API that accepts a key supports either:
- a string, such as
"reports/march.pdf" - an array of path segments, such as
["reports", "march.pdf"]
Normalization rules:
- nested arrays are flattened
- non-string values inside key arrays are ignored
- whitespace around each segment is trimmed
- leading and trailing slashes are removed from each segment
- repeated separators are collapsed to a single
/ - empty segments are discarded
Examples:
joinStorageKey(" invoices/ ", ["2026", "/march/"], "statement.pdf");
// "invoices/2026/march/statement.pdf"const storage = new StorageClient({
bucket: "assets",
publicBaseUrl: "https://cdn.example.com",
});
storage.createPublicUrl({ key: ["safe", 123, "file.txt"] });
// "https://cdn.example.com/safe/file.txt"Bucket overrides
The constructor bucket is the default bucket, but the following operations can override it per call by passing bucket inside the target object:
putObjectheadObjectdeleteObjectcreatePresignedUploadUrlcreatePresignedDownloadUrlcreatePublicUrlwhen called with the object target form
Example:
await storage.putObject({
bucket: "archive-bucket",
key: "reports/2026-03.json",
body: JSON.stringify({ ok: true }),
});Public URL derivation order
When the package needs to produce a public URL, it resolves it in this order:
buildPublicUrl(...)publicBaseUrlendpoint- AWS S3 default public URL generation
Provider-specific behavior:
- S3 can derive a public URL from
regionandurlStyleeven whenendpointis omitted - R2 cannot derive a public URL unless at least one of
buildPublicUrl,publicBaseUrl,endpoint, oraccountIdis present - if
accountIdis provided for R2 andendpointis omitted, the derived endpoint is enough for public URL generation
Important difference between methods:
- operation results such as
putObject,headObject,deleteObject, and the presign methods returnpublicUrl: nullwhen public URL generation is not possible createPublicUrl(...)is explicit and throws when public URL generation is not possible
Path-style vs virtual-hosted URLs
Examples for S3:
- path style in
eu-west-1:https://s3.eu-west-1.amazonaws.com/assets/images/logo.png - virtual-hosted in
eu-west-1:https://assets.s3.eu-west-1.amazonaws.com/images/logo.png - virtual-hosted in
us-east-1:https://assets.s3.amazonaws.com/images/logo.png
Examples for explicit endpoints:
- path style:
https://objects.example.com/root/assets/images/logo.png - virtual-hosted:
https://assets.objects.example.com/root/images/logo.png
Operation Reference
All wrapped failures use StorageError, except for local validation errors such as an empty bucket, empty key, or invalid TTL, which throw standard TypeError or RangeError.
putObject(input)
Stores an object immediately.
Required input:
keybody
Optional input:
bucketmetadatatagscontentTypecacheControlcontentDispositioncontentEncodingcontentLanguagecontentMD5expirespublicUrlcommandInput
Behavior:
metadatamerges withdefaultMetadata; per-call keys wintagsmerges withdefaultTags; per-call keys wincontentType,cacheControl,contentDisposition,contentEncoding, andcontentLanguageuse this precedence: per-call field ->commandInputfield -> constructor defaultcontentMD5andexpiresuse this precedence: per-call field ->commandInputfieldcommandInputcannot overrideBucket,Key,Body,Metadata, orTagging; the package owns those fieldspublicUrldefaults totrue; passfalseto suppress public URL generation and always returnpublicUrl: null
Result shape:
bucketkeyproviderpublicUrletagversionIdchecksumCRC32checksumCRC32CchecksumSHA1checksumSHA256
Example:
const result = await storage.putObject({
key: "exports/data.json",
body: JSON.stringify({ ok: true }),
contentType: "application/json",
commandInput: {
ChecksumAlgorithm: "SHA256",
ServerSideEncryption: "AES256",
},
});headObject(input)
Fetches object metadata.
Required input:
key
Optional input:
bucketnotFoundpublicUrlcommandInput
Behavior:
notFounddefaults to"null"- with
notFound: "null", missing objects returnnull - with
notFound: "error", missing objects throwStorageError - not-found detection covers HTTP
404,NotFound, andNoSuchKeyforms from AWS-style errors publicUrldefaults totrue
Result shape when found:
bucketkeyproviderpublicUrlexists: trueetagversionIdlastModifiedexpiresAtcontentLengthcontentTypecacheControlcontentDispositioncontentEncodingcontentLanguagemetadataraw
Example:
const metadata = await storage.headObject({
key: "images/logo.png",
notFound: "error",
});objectExists(target)
Boolean existence check built on top of headObject.
Required input:
key
Optional input:
bucket
Behavior:
- returns
truewhen the object exists - returns
falsefor404,NotFound, andNoSuchKey - still throws for other failures
- always skips public URL generation internally
Example:
const exists = await storage.objectExists({ key: "archive/2026-03.zip" });deleteObject(input)
Deletes an object.
Required input:
key
Optional input:
bucketpublicUrlcommandInput
Behavior:
publicUrldefaults totruecommandInputcannot overrideBucketorKey
Result shape:
bucketkeyproviderpublicUrlversionIddeleteMarkerraw
Example:
const result = await storage.deleteObject({
key: "private/report.pdf",
publicUrl: false,
});createPresignedUploadUrl(input)
Builds a presigned PUT request for uploading an object.
Required input:
key
Optional input:
bucketexpiresInmetadatatagscontentTypecacheControlcontentDispositioncontentEncodingcontentLanguagecontentMD5publicUrlcommandInput
Behavior:
- uses the same metadata, tags, and content-header precedence rules as
putObject expiresIndefaults todefaultUploadExpiresIncommandInputcannot overrideBucket,Key,Body,Metadata, orTagging- the generated
headersobject contains the headers the upload caller must send with the signedPUT - headers are generated for standard content fields, metadata, ACL, checksum fields, server-side encryption, storage class, and website redirect location when present in the resolved command input
Result shape:
bucketkeyproviderpublicUrlmethod: "PUT"urlheadersexpiresInexpiresAt
Example:
const upload = await storage.createPresignedUploadUrl({
key: ["avatars", "user-1.png"],
contentType: "image/png",
metadata: { uploadedBy: "admin" },
commandInput: {
ACL: "public-read",
ChecksumSHA256: "sha256",
ServerSideEncryption: "AES256",
},
});createPresignedDownloadUrl(input)
Builds a presigned GET request for downloading an object.
Required input:
key
Optional input:
bucketexpiresInpublicUrlcommandInput
Behavior:
expiresIndefaults todefaultDownloadExpiresIncommandInputcannot overrideBucketorKey- returned
headersis always an empty object
Result shape:
bucketkeyproviderpublicUrlmethod: "GET"urlheadersexpiresInexpiresAt
Example:
const download = await storage.createPresignedDownloadUrl({
key: ["reports", "march report.pdf"],
expiresIn: 300,
});createPublicUrl(input)
Returns the public URL string for an object.
Accepted input:
- a key string
- a key array
- an object target with
keyand optionalbucket
Behavior:
- applies
keyPrefixandresolveKey - uses the same public URL derivation order documented above
- throws when the key is empty
- throws
StorageErrorwhen the provider configuration cannot produce a public URL
Examples:
const url = storage.createPublicUrl("images/logo.png");const url = storage.createPublicUrl({
bucket: "archive-assets",
key: ["reports", "2026", "march.pdf"],
});joinStorageKey(...parts)
Normalizes and joins key parts using the same rules as the client.
Example:
const key = joinStorageKey("tenant-a", ["reports", "2026"], "march.pdf");
// "tenant-a/reports/2026/march.pdf"Advanced Customization Examples
Custom key resolution
const storage = new StorageClient({
bucket: "documents",
keyPrefix: "tenant-a",
resolveKey: (key, context) => `v1/${context.bucket}/${key}`,
});Notes:
resolveKeyreceives the key afterkeyPrefixhas already been appliedcontext.operationtells you which storage operation is being resolved
Custom public URL generation
const storage = new StorageClient({
bucket: "assets",
buildPublicUrl: ({ bucket, key, provider }) => {
return `https://cdn.example.com/${provider}/${bucket}/${key}`;
},
});Notes:
buildPublicUrlruns beforepublicBaseUrl,endpoint, and provider defaults- use this when URL generation depends on a CDN routing rule or custom public path contract
Raw command overrides
Use commandInput when you need lower-level AWS SDK fields that the package does not expose as first-class top-level inputs.
Examples:
ChecksumAlgorithmServerSideEncryptionACLStorageClassResponseContentTypeVersionId
await storage.putObject({
key: "exports/data.json",
body: JSON.stringify({ ok: true }),
contentType: "application/json",
commandInput: {
ChecksumAlgorithm: "SHA256",
ServerSideEncryption: "AES256",
},
});const url = await storage.createPresignedDownloadUrl({
key: "reports/march.pdf",
commandInput: {
ResponseContentDisposition: "attachment; filename=report.pdf",
},
});Error Model
Wrapped operation failures throw StorageError.
StorageError fields:
namemessagecodeoperationproviderbucketkeystatusCoderetryabledetailscause
details currently includes httpStatusCode when the upstream error exposes a status code.
Error codes by operation:
| Operation | Error code |
| --- | --- |
| putObject | STORAGE_PUT_FAILED |
| headObject | STORAGE_HEAD_FAILED |
| deleteObject | STORAGE_DELETE_FAILED |
| createPresignedUploadUrl | STORAGE_PRESIGN_UPLOAD_FAILED |
| createPresignedDownloadUrl | STORAGE_PRESIGN_DOWNLOAD_FAILED |
| createPublicUrl | STORAGE_PUBLIC_URL_FAILED |
Retryability behavior for wrapped errors:
retryableistruefor unknown-status failuresretryableistruefor429retryableistruefor>= 500retryableisfalsefor most other explicit client-side status codes such as400or403
The package extracts statusCode from these AWS-style error shapes:
error.$metadata.httpStatusCodeerror.statusCodeerror.status
Validation failures are not wrapped:
- empty bucket ->
TypeError - empty key ->
TypeError - invalid TTL type or value ->
TypeError - TTL over
604800->RangeError
Practical Notes
createPublicUrlonly formats a URL; it does not make the object public- operation results default to trying
publicUrlgeneration, but degrade tonullwhen safe derivation is not possible - use
publicUrl: falseon individual operations when you do not want any public URL work done - R2 uses the same package surface as S3; the provider differences stay in configuration, not in the operation APIs
- the package URL-encodes path segments when generating public and presigned URLs, so keys like
"march report.pdf"are emitted safely
