Files
WRNexusJSDoc/app/pages/packages/uploader.wrn
T

639 lines
45 KiB
Plaintext

page wrnexusuploader {
seo {
title = "@wrnexus/uploader"
description = "Validated local/S3 uploads and secure file serving."
}
view {
<div class="docs-shell">
<a href="#main" class="skip-link">Skip to content</a>
<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.8.7</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.8.7</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.8.7</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>
<p>Uploads can participate in security and media pipelines without changing storage drivers. Pass a <code>scan</code> hook to reject malware/DLP findings before storage, and <code>afterStore</code> to enqueue image/video processing or indexing. If post-processing throws, WRNexus deletes the newly written object so a partially accepted upload is never left behind.</p>
<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>
<h3 id="helper-and-component-kit">Helper and component kit</h3>
<p>Use <code>formatFileSize</code>, <code>uploadAccept</code>, <code>uploadedFileMap</code>, <code>uploaderAttributes</code>, and <code>assertUploadedFiles</code> to keep upload forms and server validation consistent.</p>
<p>Enable <code>uploaderPlugin()</code> for:</p>
<ul>
<li><code>&lt;UploadDropzone /&gt;</code></li>
<li><code>&lt;UploadStatus /&gt;</code></li>
</ul>
<p>The complete blocks compose <code>Card</code>, <code>Alert</code>, and <code>Badge</code> from <code>@wrnexus/ui</code>; the specialized upload runtime remains responsible for the native file input and secure transport behavior. Large files can use <code>createResumableUploadManager</code>. Sessions are bounded and expiring; chunks may arrive out of order, carry SHA-256 checksums, and are idempotent when retried. Conflicting retries reject, and the object is assembled only after every exact-sized chunk is present.</p>
<pre data-language="ts"><code>const uploads = createResumableUploadManager(&#123;
driver: getStore(&quot;documents&quot;).driver,
sessions: redisUploadSessionStore,
chunkSize: 5 * 1024 * 1024,
maxBytes: 500 * 1024 * 1024,
accept: [&quot;application/pdf&quot;],
&#125;);
const session = await uploads.create(&#123; name: &quot;report.pdf&quot;, size, type &#125;);
await uploads.uploadChunk(session.id, index, bytes, sha256);</code></pre>
<p>The included memory session store is intended for one-process apps and tests. Multi-instance production deployments should implement <code>ResumableSessionStore</code> with shared durable storage and atomic session updates, and periodically call <code>prune()</code> for abandoned uploads.</p></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';
export &#123; UploaderPluginOptions, uploaderComponentsDir, default as uploaderPlugin &#125; from './plugin.js';
import '@wrnexus/plugin';
/**
* 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;
/** Virus/DLP/content scanner invoked before bytes enter storage. Throw or return unsafe to reject. */
scan?: (file: UploadScanInput) =&gt; UploadScanResult | Promise&lt;UploadScanResult&gt;;
/** Image/video/indexing hook invoked after storage. Failure removes the just-written object. */
afterStore?: (file: UploadedFile &amp; &#123;
bytes: Uint8Array;
store: Store;
&#125;) =&gt; void | Promise&lt;void&gt;;
&#125;
interface UploadScanInput &#123;
name: string;
type: string;
size: number;
bytes: Uint8Array;
store: Store;
&#125;
interface UploadScanResult &#123;
safe: boolean;
reason?: string;
scanner?: 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 invalidate = String(root.getAttribute(\&quot;data-invalidate\&quot;) || \&quot;\&quot;)\n .split(\&quot;,\&quot;)\n .map(function (tag) &#123; return tag.trim(); &#125;)\n .filter(Boolean);\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;\n catch (error) &#123; console.warn(\&quot;[wrnexus:uploader] upload response was not valid JSON\&quot;, error); &#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 if (invalidate.length) &#123;\n window.dispatchEvent(new CustomEvent(\&quot;wrnexus:cache:invalidate\&quot;, &#123;\n detail: &#123; tags: invalidate, source: \&quot;uploader\&quot;, file: file, result: info &#125;\n &#125;));\n &#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;;
declare function formatFileSize(bytes: number, locale?: string): string;
declare function uploadAccept(value: string | readonly string[]): string;
declare function uploadedFileMap(files: readonly UploadedFile[]): Record&lt;string, UploadedFile&gt;;
declare function uploaderAttributes(options?: &#123;
store?: string;
endpoint?: string;
accept?: string | readonly string[];
maxBytes?: number;
multiple?: boolean;
field?: string;
label?: string;
&#125;): Record&lt;string, string | boolean&gt;;
declare function assertUploadedFiles(files: readonly UploadedFile[], options?: &#123;
min?: number;
max?: number;
&#125;): readonly UploadedFile[];
interface ResumableUploadSession &#123;
id: string;
key: string;
name: string;
type: string;
size: number;
chunkSize: number;
totalChunks: number;
createdAt: number;
expiresAt: number;
chunks: Record&lt;number, Uint8Array&gt;;
digests: Record&lt;number, string&gt;;
&#125;
interface ResumableSessionStore &#123;
get(id: string): Promise&lt;ResumableUploadSession | null&gt;;
put(session: ResumableUploadSession): Promise&lt;void&gt;;
delete(id: string): Promise&lt;void&gt;;
list(): Promise&lt;ResumableUploadSession[]&gt;;
&#125;
declare function memoryResumableSessionStore(): ResumableSessionStore;
interface ResumableUploadManagerOptions &#123;
driver: StorageDriver;
sessions?: ResumableSessionStore;
maxBytes?: number;
chunkSize?: number;
maxSessions?: number;
ttlMs?: number;
accept?: string[];
prefix?: string;
publicUrl?: (key: string) =&gt; string | null;
now?: () =&gt; number;
&#125;
interface CreateResumableUpload &#123;
name: string;
type?: string;
size: number;
chunkSize?: number;
&#125;
interface ResumableChunkResult &#123;
receivedChunks: number;
totalChunks: number;
complete: boolean;
file?: UploadedFile;
&#125;
interface ResumableUploadManager &#123;
create(input: CreateResumableUpload): Promise&lt;ResumableUploadSession&gt;;
uploadChunk(id: string, index: number, data: Uint8Array, sha256?: string): Promise&lt;ResumableChunkResult&gt;;
status(id: string): Promise&lt;&#123;
received: number[];
totalChunks: number;
expiresAt: number;
&#125; | null&gt;;
cancel(id: string): Promise&lt;boolean&gt;;
prune(): Promise&lt;number&gt;;
&#125;
declare function createResumableUploadManager(options: ResumableUploadManagerOptions): ResumableUploadManager;
interface QuotaUsage &#123;
owner: string;
bytes: number;
objects: number;
updatedAt: number;
&#125;
interface QuotaStore &#123;
get(owner: string): Promise&lt;QuotaUsage&gt;;
reserve(owner: string, bytes: number, limits: &#123;
bytes: number;
objects?: number;
&#125;): Promise&lt;boolean&gt;;
release(owner: string, bytes: number): Promise&lt;void&gt;;
&#125;
declare function memoryQuotaStore(): QuotaStore;
interface QuotaSqlClient &#123;
query&lt;T = any&gt;(sql: string, parameters?: unknown[]): Promise&lt;&#123;
rows: T[];
&#125;&gt;;
&#125;
/** PostgreSQL quota accounting using a single atomic conditional upsert. */
declare function postgresQuotaStore(db: QuotaSqlClient, table?: string): QuotaStore;
declare const POSTGRES_QUOTA_SCHEMA = &quot;CREATE TABLE IF NOT EXISTS wrnexus_storage_quota (owner text PRIMARY KEY, bytes bigint NOT NULL DEFAULT 0, objects integer NOT NULL DEFAULT 0, updated_at bigint NOT NULL);&quot;;
interface MultipartObjectClient &#123;
create(key: string, meta: PutMeta): Promise&lt;string&gt;;
uploadPart(uploadId: string, key: string, part: number, bytes: Uint8Array): Promise&lt;string&gt;;
complete(uploadId: string, key: string, parts: Array&lt;&#123;
part: number;
etag: string;
&#125;&gt;): Promise&lt;void&gt;;
abort(uploadId: string, key: string): Promise&lt;void&gt;;
&#125;
declare function multipartUpload(client: MultipartObjectClient, key: string, bytes: Uint8Array, meta: PutMeta, options?: &#123;
partBytes?: number;
concurrency?: number;
&#125;): Promise&lt;void&gt;;
interface TemporaryObject &#123;
key: string;
expiresAt: number;
&#125;
declare function createTemporaryObjectCleaner(driver: StorageDriver, options?: &#123;
now?: () =&gt; number;
limit?: number;
&#125;): &#123;
track(key: string, ttlMs: number): void;
cleanup(at?: number): Promise&lt;number&gt;;
snapshot: () =&gt; &#123;
tracked: number;
nextExpiry: number | undefined;
&#125;;
&#125;;
interface VideoTranscodeOptions &#123;
format: &quot;mp4&quot; | &quot;webm&quot;;
width?: number;
height?: number;
videoBitrateKbps?: number;
&#125;
declare function ffmpegVideoTranscoder(options?: &#123;
executable?: string;
spawn?: (args: string[]) =&gt; &#123;
exited: Promise&lt;number&gt;;
&#125;;
&#125;): (input: string, output: string, config: VideoTranscodeOptions) =&gt; Promise&lt;void&gt;;
export &#123; type CreateResumableUpload, type LocalStoreConfig, type MultipartObjectClient, POSTGRES_QUOTA_SCHEMA, type PutMeta, type QuotaSqlClient, type QuotaStore, type QuotaUsage, type ResumableChunkResult, type ResumableSessionStore, type ResumableUploadManager, type ResumableUploadManagerOptions, type ResumableUploadSession, type S3StoreConfig, type SignedFileToken, type StorageConfig, type StorageDriver, type Store, type StoreAccess, type StoreConfig, type StoredObject, type TemporaryObject, UPLOADS_PREFIX, UPLOAD_JS_HREF, UPLOAD_RUNTIME, UploadError, type UploadInspection, type UploadOptions, type UploadPolicy, UploadPolicyError, type UploadScanInput, type UploadScanResult, type UploadedFile, type VideoTranscodeOptions, accepts, assertUploadedFiles, configureStorage, contentTypeOf, createResumableUploadManager, createSignedFileToken, createTemporaryObjectCleaner, encodeKey, enforceUploadPolicy, extForType, extOf, ffmpegVideoTranscoder, formatFileSize, getStore, handleUpload, hasStorage, inspectUpload, localDriver, memoryQuotaStore, memoryResumableSessionStore, multipartUpload, postgresQuotaStore, s3Driver, safeObjectKey, serveFromStore, serveStoredFile, sha256Hex, signS3, sniffContentType, storeNames, storedUrl, upload, uploadAccept, uploadedFileMap, uploaderAttributes, 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-3" href="#helper-and-component-kit">Helper and component kit</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.8.7</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>
</div>
}
}