@wrnexus/uploader
Validated local/S3 uploads and secure file serving.
bun add @wrnexus/uploader@0.2.15Config-driven file uploads + serving for WRNexusJS. Declare named storage stores (local disk or any S3-compatible backend) in wrnexus.config.ts, upload with one function call, drop a drag-and-drop widget on a page, and serve files back — public or private. Zero external dependencies (S3 is signed with a built-in AWS SigV4 implementation, like the rest of the framework).
Configure
// wrnexus.config.ts
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
storage: {
default: "public",
stores: {
// Local disk, world-readable — served by the framework with a 1-year cache.
public: {
driver: "local",
dir: "uploads/public", // relative to the app root (dev) / cwd (prod)
access: "public",
maxBytes: 10_000_000,
accept: ["image/*", ".pdf"], // MIME, "type/*" wildcards, or ".ext"
},
// Private S3 (works with AWS, Cloudflare R2, Backblaze B2, MinIO, DO Spaces).
docs: {
driver: "s3",
access: "private",
bucket: "my-bucket",
region: "auto",
endpoint: "https://<acct>.r2.cloudflarestorage.com",
accessKeyId: process.env.S3_KEY!,
secretAccessKey: process.env.S3_SECRET!,
},
},
},
};
export default config;
Upload (server)
// app/api/upload.ts — one-liner
import { handleUpload } from "@wrnexus/uploader";
export const POST = handleUpload({ store: "public" });
// → { ok: true, files: [{ key, url, name, type, size }] }
// or drive it yourself, anywhere you have the request
import { upload, getStore } from "@wrnexus/uploader";
const { files } = await upload("docs", ctx.req, { prefix: "invoices" });
await getStore("docs").driver.delete(files[0].key);
Uploads are validated (size + type), stored under a random, collision-proof, path-safe key (the client filename is never used as a path), and — for public stores — returned with a servable url.
Widget (client)
Drop the element anywhere; the runtime (drag-and-drop, per-file progress, success/failed states) is auto-injected on pages that contain data-uploader:
<div
data-uploader="public"
data-endpoint="/api/upload"
data-accept="image/*"
data-max="10000000"
data-multiple
></div>
Or via the first-party UI component:
<div
data-component="file-upload"
store="public"
endpoint="/api/upload"
accept="image/*"
multiple="true"
></div>
It dispatches bubbling events you can listen for:
wrnexus:upload—detail: { file, result: { key, url, name, size, type } }wrnexus:upload-error—detail: { file, error }
Serve files
- Public + local → served automatically at
/__wrnexus/uploads/<store>/<key>(immutable cache). - Public + S3 →
urlis the bucket/CDN URL directly. - Private (any driver) → mount a route and gate it with your auth middleware:
// app/api/files/[key].ts
import { serveFromStore } from "@wrnexus/uploader";
export const GET = serveFromStore("docs"); // your middleware decides who gets in
API
| Export | What |
|---|---|
handleUpload(opts) | POST route handler → JSON { ok, files } |
upload(store, req, opts) | Parse + validate + store; returns { files } |
serveFromStore(store) | Route handler that streams an object back (gate it for private) |
getStore(name?) / hasStorage(name?) | Reach a store's driver (put/get/delete/publicUrl) |
configureStorage(config, root) | Build the registry (the framework calls this at startup) |
s3Driver / localDriver / signS3 | Lower-level building blocks |
Notes
- Uploads count against the server's
maxBodyBytes; per-file limits use each store'smaxBytes. - SigV4 signing is implemented from scratch (no
@aws-sdk); tested against local S3 semantics. - v1 buffers each file in memory up to its size cap (fine for images/docs up to tens of MB).
Live AWS/R2 connectivity depends on your credentials + bucket policy.
Complete TypeScript API
This declaration is generated from the exact published package and lists its exported functions, classes, interfaces, and types.
import { Context } from '@wrnexus/core';
/**
* Storage driver contract + config types.
*
* A `StorageDriver` is the low-level object store (local disk, S3, …). It knows
* how to put/get/delete raw bytes under a key — nothing about HTTP, multipart
* parsing, validation, or URLs. The registry (`client.ts`) builds one driver per
* configured store and the upload layer (`upload.ts`) drives them. This mirrors
* `@wrnexus/db`'s driver/adapter split.
*/
/** Whether a store's objects are world-readable or served behind app auth. */
type StoreAccess = "public" | "private";
/** An object read back from a store. */
interface StoredObject {
/** Object bytes as a web stream (preferred) or a buffer. */
body: ReadableStream<Uint8Array> | Uint8Array;
/** MIME type to serve with. */
contentType: string;
/** Size in bytes, when known. */
size?: number;
}
/** Metadata passed alongside the bytes on `put`. */
interface PutMeta {
contentType: string;
/** Original client filename (informational only — NEVER used as a path). */
filename?: string;
}
/** The low-level object store. Implementations: `adapters/local.ts`, `adapters/s3.ts`. */
interface StorageDriver {
/** Persist `data` under `key` (overwrites). */
put(key: string, data: Uint8Array, meta: PutMeta): Promise<void>;
/** Fetch an object, or `null` if it doesn't exist. */
get(key: string): Promise<StoredObject | null>;
/** Remove an object. No error if it's already gone. */
delete(key: string): Promise<void>;
/**
* A directly-servable absolute URL for a PUBLIC object (e.g. an S3/CDN URL), or
* `null` when the framework should serve it (local public stores). Private
* stores always return `null`.
*/
publicUrl(key: string): string | null;
}
/** Local-disk store. `dir` is resolved against the app root when relative. */
interface LocalStoreConfig {
driver: "local";
access: StoreAccess;
/** Directory the files live under (e.g. "uploads/public"). */
dir: string;
/** Reject files larger than this many bytes (per file). */
maxBytes?: number;
/** Allowed types: MIME (`"image/*"`, `"application/pdf"`) and/or extensions (`".pdf"`). */
accept?: string[];
}
/** S3 / S3-compatible store (AWS, Cloudflare R2, Backblaze B2, MinIO, DO Spaces). */
interface S3StoreConfig {
driver: "s3";
access: StoreAccess;
bucket: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
/**
* Custom endpoint for non-AWS services, e.g.
* `https://<acct>.r2.cloudflarestorage.com`. Omit for AWS S3.
*/
endpoint?: string;
/** Force path-style URLs (`/bucket/key`). Defaults on for custom endpoints. */
forcePathStyle?: boolean;
/** Public base URL for `publicUrl()` (a CDN or public bucket domain). */
publicBaseUrl?: string;
maxBytes?: number;
accept?: string[];
}
type StoreConfig = LocalStoreConfig | S3StoreConfig;
/** The `storage` block in `wrnexus.config.ts`. */
interface StorageConfig {
/** Name of the store used when a call omits one. Defaults to the first store. */
default?: string;
/** Named stores, reached with `getStore("<name>")` / `upload("<name>", …)`. */
stores: Record<string, StoreConfig>;
}
/**
* Process-wide store registry, configured once at server startup from the
* `storage` block in `wrnexus.config.ts` (mirrors `@wrnexus/db`'s registry).
* Handlers then call `getStore("<name>")` — or omit the name for the default.
*/
interface Store {
name: string;
access: StoreAccess;
driver: StorageDriver;
config: StoreConfig;
}
/** Build a driver per configured store. Safe to call again (fully replaces). */
declare function configureStorage(config: StorageConfig | undefined, appRoot: string): void;
/** Whether the default (or a named) store is configured. */
declare function hasStorage(name?: string): boolean;
/** The default store, or a named one. Throws if it isn't configured. */
declare function getStore(name?: string): Store;
/** Names of all configured stores. */
declare function storeNames(): string[];
/**
* The HTTP-facing upload + serve layer: parse multipart requests, validate,
* store, and serve files back. Built on the store registry (`client.ts`).
*/
/** Reserved prefix the framework serves PUBLIC local objects from. */
declare const UPLOADS_PREFIX = "/__wrnexus/uploads/";
interface UploadedFile {
/** Storage key — pass to `getStore().driver.get/delete` or a serve route. */
key: string;
/** A servable URL for public objects, or `null` for private stores. */
url: string | null;
/** Original (sanitized) client filename, for display. */
name: string;
type: string;
size: number;
}
interface UploadOptions {
/** Only read files from this form field (default: every file field). */
field?: string;
/** Override the store's `maxBytes`. */
maxBytes?: number;
/** Override the store's `accept` list. */
accept?: string[];
/** Key prefix, e.g. `"avatars"` → keys become `avatars/<yyyy>/<mm>/<rand>.<ext>`. */
prefix?: string;
}
/** A 4xx-carrying error so `handleUpload` can map it to a status. */
declare class UploadError extends Error {
readonly status: number;
constructor(message: string, status?: number);
}
/** The servable URL for a stored object (public → URL, private → null). */
declare function storedUrl(store: Store, key: string): string | null;
/**
* Read multipart file(s) from a request and store them. Throws `UploadError`
* (4xx) on validation failures. Call it directly, or use `handleUpload`.
*/
declare function upload(storeName: string | undefined, req: Request, opts?: UploadOptions): Promise<{
files: UploadedFile[];
}>;
/**
* Ready-made POST handler:
*
* // app/api/upload.ts
* export const POST = handleUpload({ store: "public" });
*
* Returns `{ ok:true, files:[…] }` on success, or `{ ok:false, error }` with a
* 4xx/5xx status.
*/
declare function handleUpload(opts?: UploadOptions & {
store?: string;
}): (ctx: Context) => Promise<Response>;
/**
* Serve an object from a store as a route handler — mount it behind your auth
* middleware to gate PRIVATE files:
*
* // app/api/files/[key].ts
* export const GET = serveFromStore("docs");
*
* Reads the key from `ctx.params.key` (or `ctx.params.path`); it may contain `/`.
*/
declare function serveFromStore(storeName?: string, opts?: {
param?: string;
}): (ctx: Context) => Promise<Response>;
/**
* Framework asset hook: serve PUBLIC local objects at
* `/__wrnexus/uploads/<store>/<key>`. Returns `null` for anything it doesn't
* own (unknown/private/S3-backed store) so the caller falls through. Wired into
* the dev + prod asset servers.
*/
declare function serveStoredFile(pathname: string): Promise<Response | null>;
/**
* Client runtime for `<div data-uploader>` elements — drag-and-drop + file
* input, per-file progress bars, and success/failed states. Injected by
* `collectScripts` only on pages that contain `data-uploader` (same mechanism as
* `validate.js`). Self-contained: it injects its own themed stylesheet (using
* `--wire-*` tokens) and posts each file via XHR so upload progress is live.
*
* Markup it enhances (also a valid no-JS `<form>` fallback if you wrap it):
* <div data-uploader="public" data-endpoint="/api/upload"
* data-accept="image/*" data-max="10000000" data-multiple></div>
*
* Events dispatched on the element (bubble):
* wrnexus:upload detail: { file, result: { key, url, name, size, type } }
* wrnexus:upload-error detail: { file, error }
*
* NOTE: written with single/double quotes + string concatenation only — no
* backticks and no ${...}, so it embeds safely in the exported template string.
*/
declare const UPLOAD_JS_HREF = "/__wrnexus/uploader.js";
declare const UPLOAD_RUNTIME = "\n(function () {\n if (typeof document === \"undefined\") return;\n var CSRF_COOKIE = \"wire-csrf\";\n\n var CSS =\n \".wire-uploader{display:block}\" +\n \".wire-uploader-zone{display:flex;align-items:center;justify-content:center;text-align:center;\" +\n \"min-height:8rem;padding:1.25rem;border:2px dashed var(--wire-border,#cbd5e1);border-radius:12px;\" +\n \"background:var(--wire-surface,transparent);color:var(--wire-muted,#64748b);cursor:pointer;\" +\n \"transition:border-color .15s ease,background-color .15s ease;position:relative}\" +\n \".wire-uploader-zone:hover,.wire-uploader-zone:focus-visible{border-color:var(--wire-brand,#3f7dff);outline:none}\" +\n \".wire-uploader-zone.is-drag{border-color:var(--wire-brand,#3f7dff);background:color-mix(in oklab,var(--wire-brand,#3f7dff) 8%,transparent)}\" +\n \".wire-uploader-prompt{font-size:.9rem;pointer-events:none}\" +\n \".wire-uploader-input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}\" +\n \".wire-uploader-list{list-style:none;margin:.75rem 0 0;padding:0;display:flex;flex-direction:column;gap:.5rem}\" +\n \".wire-uploader-item{display:grid;grid-template-columns:1fr auto;gap:.15rem .75rem;align-items:center;\" +\n \"font-size:.82rem;padding:.5rem .7rem;border:1px solid var(--wire-border,#e2e8f0);border-radius:8px}\" +\n \".wire-uploader-name{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--wire-text,#0f172a)}\" +\n \".wire-uploader-meta{color:var(--wire-muted,#94a3b8);font-variant-numeric:tabular-nums}\" +\n \".wire-uploader-bar{grid-column:1/-1;height:5px;border-radius:999px;background:var(--wire-border,#e2e8f0);overflow:hidden}\" +\n \".wire-uploader-fill{height:100%;width:0;border-radius:999px;background:var(--wire-brand,#3f7dff);transition:width .15s ease}\" +\n \".wire-uploader-status{grid-column:1/-1;font-size:.75rem;color:var(--wire-muted,#94a3b8);font-variant-numeric:tabular-nums}\" +\n \".wire-uploader-item.is-done .wire-uploader-fill{background:var(--wire-success,#16a34a)}\" +\n \".wire-uploader-item.is-done .wire-uploader-status{color:var(--wire-success,#16a34a)}\" +\n \".wire-uploader-item.is-error .wire-uploader-fill{background:var(--wire-danger,#dc2626)}\" +\n \".wire-uploader-item.is-error .wire-uploader-status{color:var(--wire-danger,#dc2626)}\";\n\n function injectCss() {\n if (document.getElementById(\"wire-uploader-css\")) return;\n var s = document.createElement(\"style\");\n s.id = \"wire-uploader-css\";\n s.textContent = CSS;\n document.head.appendChild(s);\n }\n\n function cookie(name) {\n var m = document.cookie.match(new RegExp(\"(?:^|; )\" + name + \"=([^;]*)\"));\n return m ? decodeURIComponent(m[1]) : \"\";\n }\n function el(tag, cls, text) {\n var e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text != null) e.textContent = text;\n return e;\n }\n function fmt(n) {\n if (n < 1024) return n + \" B\";\n if (n < 1048576) return (n / 1024).toFixed(1) + \" KB\";\n return (n / 1048576).toFixed(1) + \" MB\";\n }\n function accepts(accept, file) {\n var list = (accept || \"\").split(\",\").map(function (s) { return s.trim().toLowerCase(); }).filter(Boolean);\n if (!list.length) return true;\n var type = (file.type || \"\").toLowerCase();\n var name = (file.name || \"\").toLowerCase();\n var ext = name.indexOf(\".\") >= 0 ? name.slice(name.lastIndexOf(\".\")) : \"\";\n return list.some(function (rule) {\n if (rule.charAt(0) === \".\") return rule === ext;\n if (rule.slice(-2) === \"/*\") return type.indexOf(rule.slice(0, -1)) === 0;\n return rule === type;\n });\n }\n\n function setup(root) {\n if (root.__wrnexusUploader) return;\n root.__wrnexusUploader = true;\n\n var endpoint = root.getAttribute(\"data-endpoint\") || \"/api/upload\";\n var multipleAttr = root.getAttribute(\"data-multiple\");\n var multiple = root.hasAttribute(\"data-multiple\") && multipleAttr !== \"false\";\n var accept = root.getAttribute(\"data-accept\") || \"\";\n var maxBytes = parseInt(root.getAttribute(\"data-max\") || \"0\", 10) || 0;\n var field = root.getAttribute(\"data-field\") || (multiple ? \"files\" : \"file\");\n var promptText = root.getAttribute(\"data-label\") || \"Drag files here or click to browse\";\n\n root.classList.add(\"wire-uploader\");\n var zone = el(\"div\", \"wire-uploader-zone\");\n zone.setAttribute(\"role\", \"button\");\n zone.setAttribute(\"tabindex\", \"0\");\n zone.appendChild(el(\"div\", \"wire-uploader-prompt\", promptText));\n var input = document.createElement(\"input\");\n input.type = \"file\";\n input.className = \"wire-uploader-input\";\n if (multiple) input.multiple = true;\n if (accept) input.accept = accept;\n zone.appendChild(input);\n var listEl = el(\"ul\", \"wire-uploader-list\");\n root.appendChild(zone);\n root.appendChild(listEl);\n\n zone.addEventListener(\"keydown\", function (e) {\n if (e.key === \"Enter\" || e.key === \" \") { e.preventDefault(); input.click(); }\n });\n [\"dragenter\", \"dragover\"].forEach(function (ev) {\n zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add(\"is-drag\"); });\n });\n [\"dragleave\", \"drop\"].forEach(function (ev) {\n zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove(\"is-drag\"); });\n });\n zone.addEventListener(\"drop\", function (e) {\n if (e.dataTransfer && e.dataTransfer.files) handle(e.dataTransfer.files);\n });\n input.addEventListener(\"change\", function () {\n if (input.files) handle(input.files);\n input.value = \"\";\n });\n\n function handle(files) {\n var arr = Array.prototype.slice.call(files);\n if (!multiple) arr = arr.slice(0, 1);\n arr.forEach(uploadOne);\n }\n\n function row(file) {\n var li = el(\"li\", \"wire-uploader-item\");\n li.appendChild(el(\"span\", \"wire-uploader-name\", file.name));\n li.appendChild(el(\"span\", \"wire-uploader-meta\", fmt(file.size)));\n var bar = el(\"div\", \"wire-uploader-bar\");\n var fill = el(\"div\", \"wire-uploader-fill\");\n bar.appendChild(fill);\n li.appendChild(bar);\n var status = el(\"span\", \"wire-uploader-status\", \"\");\n li.appendChild(status);\n listEl.appendChild(li);\n return { li: li, fill: fill, status: status };\n }\n\n function uploadOne(file) {\n var ui = row(file);\n if (maxBytes && file.size > maxBytes) return fail(ui, \"Too large (max \" + fmt(maxBytes) + \")\", file);\n if (!accepts(accept, file)) return fail(ui, \"Type not allowed\", file);\n\n var fd = new FormData();\n fd.append(field, file, file.name);\n var xhr = new XMLHttpRequest();\n xhr.open(\"POST\", endpoint, true);\n var token = cookie(CSRF_COOKIE);\n if (token) xhr.setRequestHeader(\"x-csrf-token\", token);\n xhr.upload.addEventListener(\"progress\", function (e) {\n if (e.lengthComputable) {\n var pct = Math.round((e.loaded / e.total) * 100);\n ui.fill.style.width = pct + \"%\";\n ui.status.textContent = pct + \"%\";\n }\n });\n xhr.addEventListener(\"load\", function () {\n var data = null;\n try { data = JSON.parse(xhr.responseText); } catch (e2) {}\n if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) {\n done(ui, (data.files && data.files[0]) || null, file);\n } else {\n fail(ui, (data && data.error) || (\"Upload failed (\" + xhr.status + \")\"), file);\n }\n });\n xhr.addEventListener(\"error\", function () { fail(ui, \"Network error\", file); });\n xhr.send(fd);\n }\n\n function done(ui, info, file) {\n ui.li.classList.remove(\"is-error\");\n ui.li.classList.add(\"is-done\");\n ui.fill.style.width = \"100%\";\n ui.status.textContent = \"\\u2713 Uploaded\";\n root.dispatchEvent(new CustomEvent(\"wrnexus:upload\", { bubbles: true, detail: { file: file, result: info } }));\n }\n function fail(ui, msg, file) {\n ui.li.classList.add(\"is-error\");\n ui.status.textContent = \"\\u2717 \" + msg;\n root.dispatchEvent(new CustomEvent(\"wrnexus:upload-error\", { bubbles: true, detail: { file: file, error: msg } }));\n }\n }\n\n function init() {\n injectCss();\n var nodes = document.querySelectorAll(\"[data-uploader]\");\n for (var i = 0; i < nodes.length; i++) setup(nodes[i]);\n }\n if (document.readyState === \"loading\") document.addEventListener(\"DOMContentLoaded\", init);\n else init();\n})();\n";
/**
* Local-disk storage driver. Files live under a configured directory; keys map
* to relative paths inside it. Path traversal is rejected — a key can never
* escape the base dir.
*/
declare function localDriver(config: LocalStoreConfig, appRoot: string): StorageDriver;
/**
* S3 (and S3-compatible) storage driver — zero deps, SigV4-signed `fetch`.
* Works with AWS S3, Cloudflare R2, Backblaze B2, MinIO, DigitalOcean Spaces.
*
* Path-style vs virtual-hosted: AWS defaults to virtual-hosted
* (`bucket.s3.region.amazonaws.com`); custom endpoints (R2/MinIO) default to
* path-style (`endpoint/bucket/key`). Override with `forcePathStyle`.
*/
declare function s3Driver(config: S3StoreConfig): StorageDriver;
/**
* AWS Signature Version 4 for S3 requests — zero external deps, built on
* `node:crypto` + `fetch`. Matches the framework's zero-dep ethos (like
* `@wrnexus/ai`) and works with any S3-compatible service (AWS, Cloudflare R2,
* Backblaze B2, MinIO, DigitalOcean Spaces).
*
* Reference: docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
*/
/** Hex-encoded SHA-256 of a payload. */
declare function sha256Hex(data: Uint8Array | string): string;
/**
* Percent-encode an S3 object key for the request path. Every character except
* the RFC 3986 unreserved set is encoded; `/` between segments is preserved.
*/
declare function encodeKey(key: string): string;
interface SignInput {
method: string;
host: string;
/** Canonical URI — already `%`-encoded, begins with `/`. */
path: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
/** Hex SHA-256 of the body, or `"UNSIGNED-PAYLOAD"`. */
payloadHash: string;
/** Extra headers to sign (e.g. `content-type`). `host`/`x-amz-*` are added here. */
headers?: Record<string, string>;
date: Date;
service?: string;
}
/**
* Compute the signed header set for an S3 request. Returns the headers to send
* (lowercased names, including `authorization`, `host`, `x-amz-date`,
* `x-amz-content-sha256`).
*/
declare function signS3(input: SignInput): Record<string, string>;
/**
* Minimal extension ↔ MIME mapping + `accept` matching. Zero-dep: just a table
* big enough for the common upload types (images, docs, media, archives).
*/
/** Lowercased extension WITHOUT the dot (e.g. "png"), or "" if none. */
declare function extOf(name: string): string;
/** MIME type for a filename/key by its extension, or a safe default. */
declare function contentTypeOf(name: string, fallback?: string): string;
/** The conventional extension for a MIME type, or "" (used to name S3 keys). */
declare function extForType(type: string): string;
/**
* Does `file` (its MIME `type` + `name`) satisfy an `accept` list? Each accept
* entry is a MIME type (`"image/png"`), a wildcard MIME (`"image/*"`), or a
* dotted extension (`".pdf"`). An empty/omitted list accepts everything.
*/
declare function accepts(accept: string[] | undefined, file: {
type: string;
name: string;
}): boolean;
export { type LocalStoreConfig, type PutMeta, type S3StoreConfig, type StorageConfig, type StorageDriver, type Store, type StoreAccess, type StoreConfig, type StoredObject, UPLOADS_PREFIX, UPLOAD_JS_HREF, UPLOAD_RUNTIME, UploadError, type UploadOptions, type UploadedFile, accepts, configureStorage, contentTypeOf, encodeKey, extForType, extOf, getStore, handleUpload, hasStorage, localDriver, s3Driver, serveFromStore, serveStoredFile, sha256Hex, signS3, storeNames, storedUrl, upload };
Examples
Copy-ready examples taken from this package's published documentation.
Example 1
// wrnexus.config.ts
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
storage: {
default: "public",
stores: {
// Local disk, world-readable — served by the framework with a 1-year cache.
public: {
driver: "local",
dir: "uploads/public", // relative to the app root (dev) / cwd (prod)
access: "public",
maxBytes: 10_000_000,
accept: ["image/*", ".pdf"], // MIME, "type/*" wildcards, or ".ext"
},
// Private S3 (works with AWS, Cloudflare R2, Backblaze B2, MinIO, DO Spaces).
docs: {
driver: "s3",
access: "private",
bucket: "my-bucket",
region: "auto",
endpoint: "https://<acct>.r2.cloudflarestorage.com",
accessKeyId: process.env.S3_KEY!,
secretAccessKey: process.env.S3_SECRET!,
},
},
},
};
export default config;Example 2
// app/api/upload.ts — one-liner
import { handleUpload } from "@wrnexus/uploader";
export const POST = handleUpload({ store: "public" });
// → { ok: true, files: [{ key, url, name, type, size }] }Example 3
// or drive it yourself, anywhere you have the request
import { upload, getStore } from "@wrnexus/uploader";
const { files } = await upload("docs", ctx.req, { prefix: "invoices" });
await getStore("docs").driver.delete(files[0].key);Example 4
<div
data-uploader="public"
data-endpoint="/api/upload"
data-accept="image/*"
data-max="10000000"
data-multiple
></div>