Storage
Files & blobs. Local or S3.
createStorage() returns one driver-agnostic Storage — put,
get, getText, exists, delete, list,
and url. The local driver writes under a base directory (path-traversal-proof); the
S3 driver targets any S3-compatible store over fetch + SigV4. Your app depends only on
the interface, so filesystem ↔ S3 is a config change.
Setup #
createStorage(config) selects a driver by its driver discriminant —
local (the default) or s3. The local driver just needs a base directory.
import { createStorage } from '@apex-stack/core/server'
const storage = createStorage({ driver: 'local', dir: 'storage/app' })
Read, write, list #
put accepts a string or Uint8Array (with an optional
contentType). get returns bytes or null;
getText decodes to a string. delete is idempotent, and list
returns { path, size } entries, optionally under a prefix.
await storage.put('docs/a.txt', 'hello world', { contentType: 'text/plain' })
await storage.exists('docs/a.txt') // → true
await storage.getText('docs/a.txt') // → 'hello world'
await storage.get('docs/a.txt') // → Uint8Array | null
await storage.put('bin/blob', new Uint8Array([0, 1, 2, 255])) // binary too
await storage.list('docs/') // → [{ path: 'docs/a.txt', size: 11 }, …]
await storage.delete('docs/a.txt') // idempotent — deleting a missing key is a no-op
Public & signed URLs #
url(path) with no options returns a plain public URL; pass
{ expiresInSeconds } for a time-limited, tamper-checked signed URL. The local driver
signs with a signingSecret (it throws if you ask for a signed URL without one).
const storage = createStorage({
driver: 'local',
dir: 'storage/app',
signingSecret: process.env.APEX_STORAGE_SECRET,
})
await storage.url('img/pic.png') // → '/storage/img/pic.png'
await storage.url('img/pic.png', { expiresInSeconds: 3600 }) // → /storage/img/pic.png?sig=…&exp=…
A signed URL carries sig + exp; the server verifies both — a tampered
path, a tampered signature, the wrong secret, or a past expiry all fail closed.
S3 & compatible #
The S3 driver speaks SigV4 over fetch — no AWS SDK. url() with an expiry
returns a presigned GET; without one, a plain object URL. Point endpoint at MinIO (or
any S3-compatible store) to switch to path-style addressing.
const storage = createStorage({
driver: 's3',
bucket: 'my-bucket',
region: 'eu-west-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
})
await storage.put('avatars/1.png', bytes, { contentType: 'image/png' })
await storage.url('avatars/1.png', { expiresInSeconds: 900 }) // SigV4 presigned GET
// Custom endpoint → path-style addressing (MinIO, R2, …)
const minio = createStorage({ driver: 's3', bucket, region, accessKeyId, secretAccessKey,
endpoint: 'https://minio.example.com' })
Path safety #
Every key is normalized and confined to the base directory. A .. segment that would
escape the root is rejected with a StoragePathError — while a .. that
stays inside resolves normally.
storage.put('../escape.txt', …) and storage.get('../../etc/passwd')
both throw StoragePathError — user-supplied keys can never read or write outside the
storage root.