422 lines
33 KiB
Plaintext
422 lines
33 KiB
Plaintext
page wrnexusuploader {
|
|
seo {
|
|
title = "@wrnexus/uploader"
|
|
description = "Validated local/S3 uploads and secure file serving."
|
|
}
|
|
|
|
view {
|
|
<div class="docs-shell">
|
|
<a class="skip-link" href="#main">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="/language">Language</a><a href="/architecture">Architecture</a></nav>
|
|
<div class="topbar-actions"><a class="preview-pill" href="/access">Private preview · v0.2.19</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="/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="page package-page">
|
|
<aside class="sidebar"><a href="/packages">← All packages</a><span class="category">Data</span><h2>@wrnexus/uploader</h2><p>Validated local/S3 uploads and secure file serving.</p><span class="status status-beta">Private preview · 0.2.19</span><nav><a href="#access">Access</a><a href="#guide">Guide</a><a href="#api">Complete API</a></nav></aside>
|
|
<article id="main" class="documentation"><section class="doc-intro"><span class="eyebrow">Data · Preview</span><h1>@wrnexus/uploader</h1><p>Validated local/S3 uploads and secure file serving.</p><section id="access" class="access-callout"><h2>Private registry access required</h2><p>This package is not available from the public npm registry. After WorkRoot approves access and supplies private registry instructions, install the release-aligned package:</p><pre><code>bun add @wrnexus/uploader@0.2.19</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" class="prose"><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="configure">Configure</h3>
|
|
<pre data-language="ts"><code>// 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;</code></pre>
|
|
<h3 id="upload-server">Upload (server)</h3>
|
|
<pre data-language="ts"><code>// 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 }] }</code></pre>
|
|
<pre data-language="ts"><code>// 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);</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>
|
|
<h3 id="widget-client">Widget (client)</h3>
|
|
<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><div
|
|
data-uploader="public"
|
|
data-endpoint="/api/upload"
|
|
data-accept="image/*"
|
|
data-max="10000000"
|
|
data-multiple
|
|
></div></code></pre>
|
|
<p>Or via the first-party UI component:</p>
|
|
<pre data-language="html"><code><div
|
|
data-component="file-upload"
|
|
store="public"
|
|
endpoint="/api/upload"
|
|
accept="image/*"
|
|
multiple="true"
|
|
></div></code></pre>
|
|
<p>It dispatches bubbling events you can listen for:</p>
|
|
<ul>
|
|
<li><code>wrnexus:upload</code> — <code>detail: { file, result: { key, url, name, size, type } }</code></li>
|
|
<li><code>wrnexus:upload-error</code> — <code>detail: { file, error }</code></li>
|
|
</ul>
|
|
<h3 id="serve-files">Serve files</h3>
|
|
<ul>
|
|
<li><strong>Public + local</strong> → served automatically at <code>/__wrnexus/uploads/<store>/<key></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 { serveFromStore } from "@wrnexus/uploader";
|
|
export const GET = serveFromStore("docs"); // 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>{ ok, files }</code></td></tr><tr><td><code>upload(store, req, opts)</code></td><td>Parse + validate + store; returns <code>{ files }</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="prose api"><h2>Complete TypeScript API</h2><p>This declaration comes from the exact installed package and lists its exported functions, classes, interfaces, and types.</p><pre data-language="typescript"><code>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 };
|
|
</code></pre></section><section id="examples" class="prose examples"><h2>Examples</h2><p>Examples are taken from this package's installed documentation and must be evaluated with its requirements and stability notes.</p><div class="example-grid"><article class="example-card"><h3>Example 1</h3><pre data-language="ts"><code>// 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;</code></pre></article><article class="example-card"><h3>Example 2</h3><pre data-language="ts"><code>// 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 }] }</code></pre></article><article class="example-card"><h3>Example 3</h3><pre data-language="ts"><code>// 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);</code></pre></article><article class="example-card"><h3>Example 4</h3><pre data-language="html"><code><div
|
|
data-uploader="public"
|
|
data-endpoint="/api/upload"
|
|
data-accept="image/*"
|
|
data-max="10000000"
|
|
data-multiple
|
|
></div></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="#configure">Configure</a><a class="toc-level-3" href="#upload-server">Upload (server)</a><a class="toc-level-3" href="#widget-client">Widget (client)</a><a class="toc-level-3" href="#serve-files">Serve files</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>WRNexusJS 0.2.19 · Private Developer Preview · Bun-native · Documentation generated from installed package APIs.</footer>
|
|
</div>
|
|
}
|
|
}
|