Files
WRNexusJSDoc/app/pages/packages/uploader.wrn
T
2026-08-01 10:34:45 +05:30

461 lines
36 KiB
Plaintext

page wrnexusuploader {
seo {
title = "@wrnexus/uploader"
description = "Validated local/S3 uploads and secure file serving."
}
view {
<div class="docs-shell">
<SkipLink label="Skip to content" href="#main" class="docs-skip-link" />
<header class="topbar">
<a class="brand" href="/"><span>W</span> WRNexusJS</a>
<nav aria-label="Primary"><a href="/getting-started">Get started</a><a href="/packages">Packages</a><a href="https://component.wrnexusjs.dev/">Components</a><a href="/language">Language</a><a href="/architecture">Architecture</a></nav>
<div class="topbar-actions"><a class="preview-pill" href="/access">Private preview · v0.7.0</a><button data-wire-theme-toggle class="theme-button" aria-label="Toggle color theme" title="Toggle color theme">◐</button></div>
</header>
<div class="mobile-doc-nav"><details><summary>Browse documentation</summary><nav><a href="/getting-started">Get started</a><a href="/packages">Packages</a><a href="https://component.wrnexusjs.dev/">Components</a><a href="/language">Language</a><a href="/architecture">Architecture</a><a href="/tutorial">Tutorial</a><a href="/guides/project-structure">Guides</a><a href="/examples">Examples</a><a href="/search">Search</a></nav></details></div>
<main class="portal-main docs-layout">
<article id="main" class="documentation prose standalone package-document"><nav class="breadcrumbs" aria-label="Breadcrumb"><a href="/">Home</a><span>/</span><a href="/packages">Packages</a><span>/</span><span aria-current="page">@wrnexus/uploader</span></nav><section class="doc-intro"><span class="eyebrow">Data · Package reference</span><h1>@wrnexus/uploader</h1><p>Validated local/S3 uploads and secure file serving.</p><div class="doc-meta"><span>v0.7.0</span><span>Private registry</span><span>Data</span></div><section id="access" class="access-callout"><h2>Install the package</h2><p>After WorkRoot approves private registry access, install the release-aligned package:</p><pre><code>bun add @wrnexus/uploader@0.7.0</code><button type="button" class="copy-button" aria-label="Copy installation command">Copy</button></pre><p><a href="/access">Request preview access</a>. Never put registry tokens in source control.</p></section></section><section id="guide"><p>Config-driven file uploads + serving for <a href="https://www.npmjs.com/org/wrnexus" rel="noreferrer">WRNexusJS</a>. Declare named <strong>storage stores</strong> (local disk or any S3-compatible backend) in <code>wrnexus.config.ts</code>, 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).</p>
<h3 id="usage">Usage</h3>
<h4 id="configure-local-and-s3-stores">Configure local and S3 stores</h4>
<pre data-language="ts"><code>// wrnexus.config.ts
import type &#123; AppConfig &#125; from &quot;@wrnexus/styles&quot;;
const config: AppConfig = &#123;
storage: &#123;
default: &quot;public&quot;,
stores: &#123;
// Local disk, world-readable — served by the framework with a 1-year cache.
public: &#123;
driver: &quot;local&quot;,
dir: &quot;uploads/public&quot;, // relative to the app root (dev) / cwd (prod)
access: &quot;public&quot;,
maxBytes: 10_000_000,
accept: [&quot;image/*&quot;, &quot;.pdf&quot;], // MIME, &quot;type/*&quot; wildcards, or &quot;.ext&quot;
&#125;,
// Private S3 (works with AWS, Cloudflare R2, Backblaze B2, MinIO, DO Spaces).
docs: &#123;
driver: &quot;s3&quot;,
access: &quot;private&quot;,
bucket: &quot;my-bucket&quot;,
region: &quot;auto&quot;,
endpoint: &quot;https://&lt;acct&gt;.r2.cloudflarestorage.com&quot;,
accessKeyId: process.env.S3_KEY!,
secretAccessKey: process.env.S3_SECRET!,
&#125;,
&#125;,
&#125;,
&#125;;
export default config;</code></pre>
<h4 id="upload-from-an-api-route-or-server-function">Upload from an API route or server function</h4>
<pre data-language="ts"><code>// app/api/upload.ts — one-liner
import &#123; handleUpload &#125; from &quot;@wrnexus/uploader&quot;;
export const POST = handleUpload(&#123; store: &quot;public&quot; &#125;);
// → &#123; ok: true, files: [&#123; key, url, name, type, size &#125;] &#125;</code></pre>
<pre data-language="ts"><code>// or drive it yourself, anywhere you have the request
import &#123; upload, getStore &#125; from &quot;@wrnexus/uploader&quot;;
const &#123; files &#125; = await upload(&quot;docs&quot;, ctx.req, &#123; prefix: &quot;invoices&quot; &#125;);
await getStore(&quot;docs&quot;).driver.delete(files[0].key);</code></pre>
<p>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 <code>url</code>.</p>
<h4 id="add-a-client-upload-widget">Add a client upload widget</h4>
<p>Drop the element anywhere; the runtime (drag-and-drop, per-file progress, success/failed states) is auto-injected on pages that contain <code>data-uploader</code>:</p>
<pre data-language="html"><code>&lt;div
data-uploader=&quot;public&quot;
data-endpoint=&quot;/api/upload&quot;
data-accept=&quot;image/*&quot;
data-max=&quot;10000000&quot;
data-multiple
&gt;&lt;/div&gt;</code></pre>
<p>Or via the first-party UI component:</p>
<pre data-language="html"><code>&lt;div
data-component=&quot;file-upload&quot;
store=&quot;public&quot;
endpoint=&quot;/api/upload&quot;
accept=&quot;image/*&quot;
multiple=&quot;true&quot;
&gt;&lt;/div&gt;</code></pre>
<p>It dispatches bubbling events you can listen for:</p>
<ul>
<li><code>wrnexus:upload</code> — <code>detail: &#123; file, result: &#123; key, url, name, size, type &#125; &#125;</code></li>
<li><code>wrnexus:upload-error</code> — <code>detail: &#123; file, error &#125;</code></li>
</ul>
<h4 id="serve-private-files-behind-application-authentication">Serve private files behind application authentication</h4>
<ul>
<li><strong>Public + local</strong> → served automatically at <code>/__wrnexus/uploads/&lt;store&gt;/&lt;key&gt;</code> (immutable cache).</li>
<li><strong>Public + S3</strong> → <code>url</code> is the bucket/CDN URL directly.</li>
<li><strong>Private</strong> (any driver) → mount a route and gate it with your auth middleware:</li>
</ul>
<pre data-language="ts"><code>// app/api/files/[key].ts
import &#123; serveFromStore &#125; from &quot;@wrnexus/uploader&quot;;
export const GET = serveFromStore(&quot;docs&quot;); // your middleware decides who gets in</code></pre>
<h3 id="api">API</h3>
<div class="table-wrap"><table>
<thead><tr><th>Export</th><th>What</th></tr></thead>
<tbody><tr><td><code>handleUpload(opts)</code></td><td>POST route handler → JSON <code>&#123; ok, files &#125;</code></td></tr><tr><td><code>upload(store, req, opts)</code></td><td>Parse + validate + store; returns <code>&#123; files &#125;</code></td></tr><tr><td><code>serveFromStore(store)</code></td><td>Route handler that streams an object back (gate it for private)</td></tr><tr><td><code>getStore(name?)</code> / <code>hasStorage(name?)</code></td><td>Reach a store's <code>driver</code> (<code>put</code>/<code>get</code>/<code>delete</code>/<code>publicUrl</code>)</td></tr><tr><td><code>configureStorage(config, root)</code></td><td>Build the registry (the framework calls this at startup)</td></tr><tr><td><code>s3Driver</code> / <code>localDriver</code> / <code>signS3</code></td><td>Lower-level building blocks</td></tr></tbody></table></div>
<h3 id="notes">Notes</h3>
<ul>
<li>Uploads count against the server's <code>maxBodyBytes</code>; per-file limits use each store's <code>maxBytes</code>.</li>
<li>SigV4 signing is implemented from scratch (no <code>@aws-sdk</code>); tested against local S3 semantics.</li>
<p>Live AWS/R2 connectivity depends on your credentials + bucket policy.</p>
<li>v1 buffers each file in memory up to its size cap (fine for images/docs up to tens of MB).</li>
</ul></section><section id="api" class="api"><h2>Complete TypeScript API</h2><p>Generated from the exact installed package declarations.</p><pre data-language="typescript"><code>import &#123; Context &#125; 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 = &quot;public&quot; | &quot;private&quot;;
/** An object read back from a store. */
interface StoredObject &#123;
/** Object bytes as a web stream (preferred) or a buffer. */
body: ReadableStream&lt;Uint8Array&gt; | Uint8Array;
/** MIME type to serve with. */
contentType: string;
/** Size in bytes, when known. */
size?: number;
&#125;
/** Metadata passed alongside the bytes on `put`. */
interface PutMeta &#123;
contentType: string;
/** Original client filename (informational only — NEVER used as a path). */
filename?: string;
&#125;
/** The low-level object store. Implementations: `adapters/local.ts`, `adapters/s3.ts`. */
interface StorageDriver &#123;
/** Persist `data` under `key` (overwrites). */
put(key: string, data: Uint8Array, meta: PutMeta): Promise&lt;void&gt;;
/** Fetch an object, or `null` if it doesn't exist. */
get(key: string): Promise&lt;StoredObject | null&gt;;
/** Remove an object. No error if it's already gone. */
delete(key: string): Promise&lt;void&gt;;
/**
* 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;
&#125;
/** Local-disk store. `dir` is resolved against the app root when relative. */
interface LocalStoreConfig &#123;
driver: &quot;local&quot;;
access: StoreAccess;
/** Directory the files live under (e.g. &quot;uploads/public&quot;). */
dir: string;
/** Reject files larger than this many bytes (per file). */
maxBytes?: number;
/** Allowed types: MIME (`&quot;image/*&quot;`, `&quot;application/pdf&quot;`) and/or extensions (`&quot;.pdf&quot;`). */
accept?: string[];
&#125;
/** S3 / S3-compatible store (AWS, Cloudflare R2, Backblaze B2, MinIO, DO Spaces). */
interface S3StoreConfig &#123;
driver: &quot;s3&quot;;
access: StoreAccess;
bucket: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
/**
* Custom endpoint for non-AWS services, e.g.
* `https://&lt;acct&gt;.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[];
&#125;
type StoreConfig = LocalStoreConfig | S3StoreConfig;
/** The `storage` block in `wrnexus.config.ts`. */
interface StorageConfig &#123;
/** Name of the store used when a call omits one. Defaults to the first store. */
default?: string;
/** Named stores, reached with `getStore(&quot;&lt;name&gt;&quot;)` / `upload(&quot;&lt;name&gt;&quot;, …)`. */
stores: Record&lt;string, StoreConfig&gt;;
&#125;
/**
* 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(&quot;&lt;name&gt;&quot;)` — or omit the name for the default.
*/
interface Store &#123;
name: string;
access: StoreAccess;
driver: StorageDriver;
config: StoreConfig;
&#125;
/** 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 = &quot;/__wrnexus/uploads/&quot;;
interface UploadedFile &#123;
/** 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;
&#125;
interface UploadOptions &#123;
/** 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. `&quot;avatars&quot;` → keys become `avatars/&lt;yyyy&gt;/&lt;mm&gt;/&lt;rand&gt;.&lt;ext&gt;`. */
prefix?: string;
&#125;
/** A 4xx-carrying error so `handleUpload` can map it to a status. */
declare class UploadError extends Error &#123;
readonly status: number;
constructor(message: string, status?: number);
&#125;
/** 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&lt;&#123;
files: UploadedFile[];
&#125;&gt;;
/**
* Ready-made POST handler:
*
* // app/api/upload.ts
* export const POST = handleUpload(&#123; store: &quot;public&quot; &#125;);
*
* Returns `&#123; ok:true, files:[…] &#125;` on success, or `&#123; ok:false, error &#125;` with a
* 4xx/5xx status.
*/
declare function handleUpload(opts?: UploadOptions &amp; &#123;
store?: string;
&#125;): (ctx: Context) =&gt; Promise&lt;Response&gt;;
/**
* 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(&quot;docs&quot;);
*
* Reads the key from `ctx.params.key` (or `ctx.params.path`); it may contain `/`.
*/
declare function serveFromStore(storeName?: string, opts?: &#123;
param?: string;
&#125;): (ctx: Context) =&gt; Promise&lt;Response&gt;;
/**
* Framework asset hook: serve PUBLIC local objects at
* `/__wrnexus/uploads/&lt;store&gt;/&lt;key&gt;`. 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&lt;Response | null&gt;;
/**
* Client runtime for `&lt;div data-uploader&gt;` 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 `&lt;form&gt;` fallback if you wrap it):
* &lt;div data-uploader=&quot;public&quot; data-endpoint=&quot;/api/upload&quot;
* data-accept=&quot;image/*&quot; data-max=&quot;10000000&quot; data-multiple&gt;&lt;/div&gt;
*
* Events dispatched on the element (bubble):
* wrnexus:upload detail: &#123; file, result: &#123; key, url, name, size, type &#125; &#125;
* wrnexus:upload-error detail: &#123; file, error &#125;
*
* NOTE: written with single/double quotes + string concatenation only — no
* backticks and no $&#123;...&#125;, so it embeds safely in the exported template string.
*/
declare const UPLOAD_JS_HREF = &quot;/__wrnexus/uploader.js&quot;;
declare const UPLOAD_RUNTIME = &quot;\n(function () &#123;\n if (typeof document === \&quot;undefined\&quot;) return;\n var CSRF_COOKIE = \&quot;wire-csrf\&quot;;\n\n var CSS =\n \&quot;.wire-uploader&#123;display:block&#125;\&quot; +\n \&quot;.wire-uploader-zone&#123;display:flex;align-items:center;justify-content:center;text-align:center;\&quot; +\n \&quot;min-height:8rem;padding:1.25rem;border:2px dashed var(--wire-border,#cbd5e1);border-radius:12px;\&quot; +\n \&quot;background:var(--wire-surface,transparent);color:var(--wire-muted,#64748b);cursor:pointer;\&quot; +\n \&quot;transition:border-color .15s ease,background-color .15s ease;position:relative&#125;\&quot; +\n \&quot;.wire-uploader-zone:hover,.wire-uploader-zone:focus-visible&#123;border-color:var(--wire-brand,#3f7dff);outline:none&#125;\&quot; +\n \&quot;.wire-uploader-zone.is-drag&#123;border-color:var(--wire-brand,#3f7dff);background:color-mix(in oklab,var(--wire-brand,#3f7dff) 8%,transparent)&#125;\&quot; +\n \&quot;.wire-uploader-prompt&#123;font-size:.9rem;pointer-events:none&#125;\&quot; +\n \&quot;.wire-uploader-input&#123;position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer&#125;\&quot; +\n \&quot;.wire-uploader-list&#123;list-style:none;margin:.75rem 0 0;padding:0;display:flex;flex-direction:column;gap:.5rem&#125;\&quot; +\n \&quot;.wire-uploader-item&#123;display:grid;grid-template-columns:1fr auto;gap:.15rem .75rem;align-items:center;\&quot; +\n \&quot;font-size:.82rem;padding:.5rem .7rem;border:1px solid var(--wire-border,#e2e8f0);border-radius:8px&#125;\&quot; +\n \&quot;.wire-uploader-name&#123;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--wire-text,#0f172a)&#125;\&quot; +\n \&quot;.wire-uploader-meta&#123;color:var(--wire-muted,#94a3b8);font-variant-numeric:tabular-nums&#125;\&quot; +\n \&quot;.wire-uploader-bar&#123;grid-column:1/-1;height:5px;border-radius:999px;background:var(--wire-border,#e2e8f0);overflow:hidden&#125;\&quot; +\n \&quot;.wire-uploader-fill&#123;height:100%;width:0;border-radius:999px;background:var(--wire-brand,#3f7dff);transition:width .15s ease&#125;\&quot; +\n \&quot;.wire-uploader-status&#123;grid-column:1/-1;font-size:.75rem;color:var(--wire-muted,#94a3b8);font-variant-numeric:tabular-nums&#125;\&quot; +\n \&quot;.wire-uploader-item.is-done .wire-uploader-fill&#123;background:var(--wire-success,#16a34a)&#125;\&quot; +\n \&quot;.wire-uploader-item.is-done .wire-uploader-status&#123;color:var(--wire-success,#16a34a)&#125;\&quot; +\n \&quot;.wire-uploader-item.is-error .wire-uploader-fill&#123;background:var(--wire-danger,#dc2626)&#125;\&quot; +\n \&quot;.wire-uploader-item.is-error .wire-uploader-status&#123;color:var(--wire-danger,#dc2626)&#125;\&quot;;\n\n function injectCss() &#123;\n if (document.getElementById(\&quot;wire-uploader-css\&quot;)) return;\n var s = document.createElement(\&quot;style\&quot;);\n s.id = \&quot;wire-uploader-css\&quot;;\n s.textContent = CSS;\n document.head.appendChild(s);\n &#125;\n\n function cookie(name) &#123;\n var m = document.cookie.match(new RegExp(\&quot;(?:^|; )\&quot; + name + \&quot;=([^;]*)\&quot;));\n return m ? decodeURIComponent(m[1]) : \&quot;\&quot;;\n &#125;\n function el(tag, cls, text) &#123;\n var e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text != null) e.textContent = text;\n return e;\n &#125;\n function fmt(n) &#123;\n if (n &lt; 1024) return n + \&quot; B\&quot;;\n if (n &lt; 1048576) return (n / 1024).toFixed(1) + \&quot; KB\&quot;;\n return (n / 1048576).toFixed(1) + \&quot; MB\&quot;;\n &#125;\n function accepts(accept, file) &#123;\n var list = (accept || \&quot;\&quot;).split(\&quot;,\&quot;).map(function (s) &#123; return s.trim().toLowerCase(); &#125;).filter(Boolean);\n if (!list.length) return true;\n var type = (file.type || \&quot;\&quot;).toLowerCase();\n var name = (file.name || \&quot;\&quot;).toLowerCase();\n var ext = name.indexOf(\&quot;.\&quot;) &gt;= 0 ? name.slice(name.lastIndexOf(\&quot;.\&quot;)) : \&quot;\&quot;;\n return list.some(function (rule) &#123;\n if (rule.charAt(0) === \&quot;.\&quot;) return rule === ext;\n if (rule.slice(-2) === \&quot;/*\&quot;) return type.indexOf(rule.slice(0, -1)) === 0;\n return rule === type;\n &#125;);\n &#125;\n\n function setup(root) &#123;\n if (root.__wrnexusUploader) return;\n root.__wrnexusUploader = true;\n\n var endpoint = root.getAttribute(\&quot;data-endpoint\&quot;) || \&quot;/api/upload\&quot;;\n var multipleAttr = root.getAttribute(\&quot;data-multiple\&quot;);\n var multiple = root.hasAttribute(\&quot;data-multiple\&quot;) &amp;&amp; multipleAttr !== \&quot;false\&quot;;\n var accept = root.getAttribute(\&quot;data-accept\&quot;) || \&quot;\&quot;;\n var maxBytes = parseInt(root.getAttribute(\&quot;data-max\&quot;) || \&quot;0\&quot;, 10) || 0;\n var field = root.getAttribute(\&quot;data-field\&quot;) || (multiple ? \&quot;files\&quot; : \&quot;file\&quot;);\n var promptText = root.getAttribute(\&quot;data-label\&quot;) || \&quot;Drag files here or click to browse\&quot;;\n\n root.classList.add(\&quot;wire-uploader\&quot;);\n var zone = el(\&quot;div\&quot;, \&quot;wire-uploader-zone\&quot;);\n zone.setAttribute(\&quot;role\&quot;, \&quot;button\&quot;);\n zone.setAttribute(\&quot;tabindex\&quot;, \&quot;0\&quot;);\n zone.appendChild(el(\&quot;div\&quot;, \&quot;wire-uploader-prompt\&quot;, promptText));\n var input = document.createElement(\&quot;input\&quot;);\n input.type = \&quot;file\&quot;;\n input.className = \&quot;wire-uploader-input\&quot;;\n if (multiple) input.multiple = true;\n if (accept) input.accept = accept;\n zone.appendChild(input);\n var listEl = el(\&quot;ul\&quot;, \&quot;wire-uploader-list\&quot;);\n root.appendChild(zone);\n root.appendChild(listEl);\n\n zone.addEventListener(\&quot;keydown\&quot;, function (e) &#123;\n if (e.key === \&quot;Enter\&quot; || e.key === \&quot; \&quot;) &#123; e.preventDefault(); input.click(); &#125;\n &#125;);\n [\&quot;dragenter\&quot;, \&quot;dragover\&quot;].forEach(function (ev) &#123;\n zone.addEventListener(ev, function (e) &#123; e.preventDefault(); zone.classList.add(\&quot;is-drag\&quot;); &#125;);\n &#125;);\n [\&quot;dragleave\&quot;, \&quot;drop\&quot;].forEach(function (ev) &#123;\n zone.addEventListener(ev, function (e) &#123; e.preventDefault(); zone.classList.remove(\&quot;is-drag\&quot;); &#125;);\n &#125;);\n zone.addEventListener(\&quot;drop\&quot;, function (e) &#123;\n if (e.dataTransfer &amp;&amp; e.dataTransfer.files) handle(e.dataTransfer.files);\n &#125;);\n input.addEventListener(\&quot;change\&quot;, function () &#123;\n if (input.files) handle(input.files);\n input.value = \&quot;\&quot;;\n &#125;);\n\n function handle(files) &#123;\n var arr = Array.prototype.slice.call(files);\n if (!multiple) arr = arr.slice(0, 1);\n arr.forEach(uploadOne);\n &#125;\n\n function row(file) &#123;\n var li = el(\&quot;li\&quot;, \&quot;wire-uploader-item\&quot;);\n li.appendChild(el(\&quot;span\&quot;, \&quot;wire-uploader-name\&quot;, file.name));\n li.appendChild(el(\&quot;span\&quot;, \&quot;wire-uploader-meta\&quot;, fmt(file.size)));\n var bar = el(\&quot;div\&quot;, \&quot;wire-uploader-bar\&quot;);\n var fill = el(\&quot;div\&quot;, \&quot;wire-uploader-fill\&quot;);\n bar.appendChild(fill);\n li.appendChild(bar);\n var status = el(\&quot;span\&quot;, \&quot;wire-uploader-status\&quot;, \&quot;\&quot;);\n li.appendChild(status);\n listEl.appendChild(li);\n return &#123; li: li, fill: fill, status: status &#125;;\n &#125;\n\n function uploadOne(file) &#123;\n var ui = row(file);\n if (maxBytes &amp;&amp; file.size &gt; maxBytes) return fail(ui, \&quot;Too large (max \&quot; + fmt(maxBytes) + \&quot;)\&quot;, file);\n if (!accepts(accept, file)) return fail(ui, \&quot;Type not allowed\&quot;, file);\n\n var fd = new FormData();\n fd.append(field, file, file.name);\n var xhr = new XMLHttpRequest();\n xhr.open(\&quot;POST\&quot;, endpoint, true);\n var token = cookie(CSRF_COOKIE);\n if (token) xhr.setRequestHeader(\&quot;x-csrf-token\&quot;, token);\n xhr.upload.addEventListener(\&quot;progress\&quot;, function (e) &#123;\n if (e.lengthComputable) &#123;\n var pct = Math.round((e.loaded / e.total) * 100);\n ui.fill.style.width = pct + \&quot;%\&quot;;\n ui.status.textContent = pct + \&quot;%\&quot;;\n &#125;\n &#125;);\n xhr.addEventListener(\&quot;load\&quot;, function () &#123;\n var data = null;\n try &#123; data = JSON.parse(xhr.responseText); &#125; catch (e2) &#123;&#125;\n if (xhr.status &gt;= 200 &amp;&amp; xhr.status &lt; 300 &amp;&amp; data &amp;&amp; data.ok) &#123;\n done(ui, (data.files &amp;&amp; data.files[0]) || null, file);\n &#125; else &#123;\n fail(ui, (data &amp;&amp; data.error) || (\&quot;Upload failed (\&quot; + xhr.status + \&quot;)\&quot;), file);\n &#125;\n &#125;);\n xhr.addEventListener(\&quot;error\&quot;, function () &#123; fail(ui, \&quot;Network error\&quot;, file); &#125;);\n xhr.send(fd);\n &#125;\n\n function done(ui, info, file) &#123;\n ui.li.classList.remove(\&quot;is-error\&quot;);\n ui.li.classList.add(\&quot;is-done\&quot;);\n ui.fill.style.width = \&quot;100%\&quot;;\n ui.status.textContent = \&quot;\\u2713 Uploaded\&quot;;\n root.dispatchEvent(new CustomEvent(\&quot;wrnexus:upload\&quot;, &#123; bubbles: true, detail: &#123; file: file, result: info &#125; &#125;));\n &#125;\n function fail(ui, msg, file) &#123;\n ui.li.classList.add(\&quot;is-error\&quot;);\n ui.status.textContent = \&quot;\\u2717 \&quot; + msg;\n root.dispatchEvent(new CustomEvent(\&quot;wrnexus:upload-error\&quot;, &#123; bubbles: true, detail: &#123; file: file, error: msg &#125; &#125;));\n &#125;\n &#125;\n\n function init() &#123;\n injectCss();\n var nodes = document.querySelectorAll(\&quot;[data-uploader]\&quot;);\n for (var i = 0; i &lt; nodes.length; i++) setup(nodes[i]);\n &#125;\n if (document.readyState === \&quot;loading\&quot;) document.addEventListener(\&quot;DOMContentLoaded\&quot;, init);\n else init();\n&#125;)();\n&quot;;
/**
* 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 &#123;
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 `&quot;UNSIGNED-PAYLOAD&quot;`. */
payloadHash: string;
/** Extra headers to sign (e.g. `content-type`). `host`/`x-amz-*` are added here. */
headers?: Record&lt;string, string&gt;;
date: Date;
service?: string;
&#125;
/**
* 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&lt;string, string&gt;;
/**
* 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. &quot;png&quot;), or &quot;&quot; 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 &quot;&quot; (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 (`&quot;image/png&quot;`), a wildcard MIME (`&quot;image/*&quot;`), or a
* dotted extension (`&quot;.pdf&quot;`). An empty/omitted list accepts everything.
*/
declare function accepts(accept: string[] | undefined, file: &#123;
type: string;
name: string;
&#125;): boolean;
interface UploadPolicy &#123;
maxBytes?: number;
accept?: string[];
requireChecksum?: boolean;
filenamePattern?: RegExp;
&#125;
interface UploadInspection &#123;
filename: string;
contentType: string;
size: number;
sha256: string;
extension: string;
&#125;
declare class UploadPolicyError extends Error &#123;
readonly code: string;
constructor(message: string, code: string);
&#125;
declare function safeObjectKey(filename: string, prefix?: string): string;
declare function inspectUpload(filename: string, bytes: Uint8Array, declaredType?: string): Promise&lt;UploadInspection&gt;;
declare function enforceUploadPolicy(inspection: UploadInspection, policy: UploadPolicy, expectedChecksum?: string): void;
declare function sniffContentType(bytes: Uint8Array): string | null;
interface SignedFileToken &#123;
store: string;
key: string;
expiresAt: number;
disposition?: &quot;inline&quot; | &quot;attachment&quot;;
&#125;
declare function createSignedFileToken(input: SignedFileToken, secret: string): Promise&lt;string&gt;;
declare function verifySignedFileToken(token: string, secret: string, now?: number): Promise&lt;SignedFileToken | null&gt;;
export &#123; type LocalStoreConfig, type PutMeta, type S3StoreConfig, type SignedFileToken, type StorageConfig, type StorageDriver, type Store, type StoreAccess, type StoreConfig, type StoredObject, UPLOADS_PREFIX, UPLOAD_JS_HREF, UPLOAD_RUNTIME, UploadError, type UploadInspection, type UploadOptions, type UploadPolicy, UploadPolicyError, type UploadedFile, accepts, configureStorage, contentTypeOf, createSignedFileToken, encodeKey, enforceUploadPolicy, extForType, extOf, getStore, handleUpload, hasStorage, inspectUpload, localDriver, s3Driver, safeObjectKey, serveFromStore, serveStoredFile, sha256Hex, signS3, sniffContentType, storeNames, storedUrl, upload, verifySignedFileToken &#125;;
</code></pre></section><section id="examples" class="examples"><h2>Examples</h2><p>Copy-ready examples from the installed package documentation.</p><div class="example-grid"><article class="example-card"><h3>Configure local and S3 stores</h3><pre data-language="ts"><code>// wrnexus.config.ts
import type &#123; AppConfig &#125; from &quot;@wrnexus/styles&quot;;
const config: AppConfig = &#123;
storage: &#123;
default: &quot;public&quot;,
stores: &#123;
// Local disk, world-readable — served by the framework with a 1-year cache.
public: &#123;
driver: &quot;local&quot;,
dir: &quot;uploads/public&quot;, // relative to the app root (dev) / cwd (prod)
access: &quot;public&quot;,
maxBytes: 10_000_000,
accept: [&quot;image/*&quot;, &quot;.pdf&quot;], // MIME, &quot;type/*&quot; wildcards, or &quot;.ext&quot;
&#125;,
// Private S3 (works with AWS, Cloudflare R2, Backblaze B2, MinIO, DO Spaces).
docs: &#123;
driver: &quot;s3&quot;,
access: &quot;private&quot;,
bucket: &quot;my-bucket&quot;,
region: &quot;auto&quot;,
endpoint: &quot;https://&lt;acct&gt;.r2.cloudflarestorage.com&quot;,
accessKeyId: process.env.S3_KEY!,
secretAccessKey: process.env.S3_SECRET!,
&#125;,
&#125;,
&#125;,
&#125;;
export default config;</code></pre></article><article class="example-card"><h3>Upload from an API route or server function</h3><pre data-language="ts"><code>// app/api/upload.ts — one-liner
import &#123; handleUpload &#125; from &quot;@wrnexus/uploader&quot;;
export const POST = handleUpload(&#123; store: &quot;public&quot; &#125;);
// → &#123; ok: true, files: [&#123; key, url, name, type, size &#125;] &#125;</code></pre></article><article class="example-card"><h3>Upload from an API route or server function</h3><pre data-language="ts"><code>// or drive it yourself, anywhere you have the request
import &#123; upload, getStore &#125; from &quot;@wrnexus/uploader&quot;;
const &#123; files &#125; = await upload(&quot;docs&quot;, ctx.req, &#123; prefix: &quot;invoices&quot; &#125;);
await getStore(&quot;docs&quot;).driver.delete(files[0].key);</code></pre></article><article class="example-card"><h3>Add a client upload widget</h3><pre data-language="html"><code>&lt;div
data-uploader=&quot;public&quot;
data-endpoint=&quot;/api/upload&quot;
data-accept=&quot;image/*&quot;
data-max=&quot;10000000&quot;
data-multiple
&gt;&lt;/div&gt;</code></pre></article><article class="example-card"><h3>Add a client upload widget</h3><pre data-language="html"><code>&lt;div
data-component=&quot;file-upload&quot;
store=&quot;public&quot;
endpoint=&quot;/api/upload&quot;
accept=&quot;image/*&quot;
multiple=&quot;true&quot;
&gt;&lt;/div&gt;</code></pre></article><article class="example-card"><h3>Serve private files behind application authentication</h3><pre data-language="ts"><code>// app/api/files/[key].ts
import &#123; serveFromStore &#125; from &quot;@wrnexus/uploader&quot;;
export const GET = serveFromStore(&quot;docs&quot;); // your middleware decides who gets in</code></pre></article></div></section></article>
<aside class="on-this-page"><h2>On this page</h2><nav><a class="toc-level-2" href="#guide">Guide</a><a class="toc-level-3" href="#usage">Usage</a><a class="toc-level-4" href="#configure-local-and-s3-stores">Configure local and S3 stores</a><a class="toc-level-4" href="#upload-from-an-api-route-or-server-function">Upload from an API route or server function</a><a class="toc-level-4" href="#add-a-client-upload-widget">Add a client upload widget</a><a class="toc-level-4" href="#serve-private-files-behind-application-authentication">Serve private files behind application authentication</a><a class="toc-level-3" href="#api">API</a><a class="toc-level-3" href="#notes">Notes</a><a class="toc-level-2" href="#api">Complete API</a><a class="toc-level-2" href="#examples">Examples</a></nav></aside>
</main>
<footer><div class="footer-brand"><span class="footer-mark" aria-hidden="true">W</span><p><strong>WRNexusJS 0.7.0</strong><span>Complete API documentation generated from installed package declarations.</span></p></div><nav aria-label="Footer"><a href="/packages">All packages</a><a href="/getting-started">Get started</a><a href="/security">Security</a><a href="/support">Support</a><a href="/llms.txt">AI guide</a></nav><p class="footer-meta">Private Developer Preview · Bun-native</p></footer>
<BackToTop />
</div>
}
}