Skip to content

File Storage

WebJs ships a pluggable file-storage primitive for uploaded File / Blob payloads. It mirrors the cache and session adapters: a documented FileStore interface, a default on-disk adapter (diskStore), and a module singleton (getFileStore / setFileStore) so an app swaps the backend in one call without touching any call site. The default lands bytes on local disk, and the same shape is S3-pluggable for production.

import { getFileStore, setFileStore, diskStore, generateKey, signedUrl, verifySignedUrl } from '@webjsdev/server';

The FileStore interface

Every method operates on web-standard objects, so an S3-compatible adapter is a drop-in (see below).

MethodShape
put(key, file, opts?)Stream a File / Blob / ReadableStream / Uint8Array to storage. Returns { key, size, contentType }.
get(key)Returns { body, size, contentType } (a STREAMING handle) or null. The serving route does new Response(handle.body, { headers }).
delete(key)Remove the object. Idempotent (a missing key is not an error).
url(key)The served URL (<baseUrl>/<key> for diskStore).
has(key)Whether the key exists (optional).

get() returns a STREAMING handle (body is a stream), not a Blob, so a serving route streams the file to the client without reading it into memory. The write path is streaming too, so a large upload uses constant memory. The upstream body-size cap (maxMultipartBytes, default 10 MiB) bounds the upload before the bytes reach the store, so the store does not re-implement that limit, it only stays streaming.

Reading and swapping the active store

Read the active store with getFileStore(), swap it once at startup with setFileStore(store). Every call site reads through getFileStore(), so a single setFileStore call changes the backend everywhere.

import { getFileStore } from '@webjsdev/server';

const { key, size, contentType } = await getFileStore().put(generateKey(file.name), file);

diskStore (the default adapter)

The default store is a diskStore rooted at <cwd>/.webjs/uploads, served under /uploads. Override the root and base URL at startup:

import { setFileStore, diskStore } from '@webjsdev/server';

setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));

Add the uploads directory to .gitignore, because it holds user data, not source.

Traversal-safe keys

Every key is resolved to an absolute path under dir and rejected if it escapes, using the same containment guard the /public/* serve path uses. A key with .., an absolute path, a leading slash, a NUL byte, a backslash, or the reserved .meta suffix throws (assertSafeKey) before any filesystem operation. Never trust a user-supplied filename as a key. Use generateKey:

const key = generateKey(file.name);   // <uuid>.<ext>, opaque + safe

generateKey(filename?) returns a random crypto.randomUUID() key, preserving only a whitelisted, sanitized extension from the original filename. A malicious '../../x.sh' yields a bare opaque key with no path and no unsafe extension.

Signed URLs (gated serving)

signedUrl / verifySignedUrl mint and verify an expiring HMAC-SHA256 (base64url) signature over the exact key plus its expiry, so a serving route can gate access without a session lookup. Neither the key nor the expiry can be tampered with (both are signed), and the comparison is constant-time.

const url = signedUrl(key, { secret: process.env.AUTH_SECRET, expiresIn: 3600 });

// in the serving route:
const check = verifySignedUrl(new URL(request.url).searchParams, process.env.AUTH_SECRET);
if (!check.valid) return new Response('Forbidden', { status: 403 });

An explicit expiresIn of 0 or a negative number fails CLOSED (the minted URL is already expired), so a "no access" intent never silently becomes a 1-hour grant. The 1-hour default applies only when expiresIn is omitted.

Recipe: upload, persist, and serve back

A file upload is a <form enctype="multipart/form-data"> posting to a page action. With JS disabled it is a native round-trip, with JS the client router upgrades it in place. No upload library, no fetch. The bytes are streamed to storage via getFileStore(), never buffered whole.

// app/avatar/page.ts
import { html } from '@webjsdev/core';
import { saveAvatar } from '#modules/avatar/actions/save-avatar.server.ts';

export async function action({ formData }: { formData: FormData }) {
  const file = formData.get('avatar');               // a web File
  if (!(file instanceof File) || file.size === 0) {
    return { success: false, fieldErrors: { avatar: 'Choose an image' }, status: 422 };
  }
  const result = await saveAvatar(file);             // persists + returns the key
  if (!result.success) return result;
  return { success: true, redirect: '/avatar' };
}

export default function Avatar({ actionData }: {
  actionData?: { fieldErrors?: Record<string, string> };
}) {
  const errors = actionData?.fieldErrors || {};
  return html`
    <form method="POST" enctype="multipart/form-data" class="flex flex-col gap-3">
      <input name="avatar" type="file" accept="image/*" required>
      ${errors.avatar ? html`<p class="text-sm text-red-600">${errors.avatar}</p>` : ''}
      <button type="submit">Upload</button>
    </form>
  `;
}

The page action delegates to a 'use server' action that streams the file to storage with a generated, traversal-safe key and persists that key on the DB row.

// modules/avatar/actions/save-avatar.server.ts
'use server';
import { getFileStore, generateKey } from '@webjsdev/server';
import { eq } from 'drizzle-orm';
import { db } from '#db/connection.server.ts';
import { users } from '#db/schema.server.ts';

export async function saveAvatar(file: File) {
  const key = generateKey(file.name);                // <uuid>.<ext>, safe
  const { size, contentType } = await getFileStore().put(key, file); // streams to disk
  if (size > 5 * 1024 * 1024) {                      // app-level policy check
    await getFileStore().delete(key);
    return { success: false, fieldErrors: { avatar: 'Max 5 MB' }, status: 422 };
  }
  await db.update(users).set({ avatarKey: key }).where(eq(users.id, 'me'));
  return { success: true, data: { key, contentType } };
}

Serve the stored file from a route.ts, streaming get(key) and gating it behind a signed URL so the object is not world-readable by key alone.

// app/files/[key]/route.ts
import { getFileStore, verifySignedUrl } from '@webjsdev/server';

export async function GET(request: Request, { params }: { params: { key: string } }) {
  const check = verifySignedUrl(new URL(request.url).searchParams, process.env.AUTH_SECRET!);
  if (!check.valid || check.key !== params.key) {
    return new Response('Forbidden', { status: 403 });
  }
  const handle = await getFileStore().get(params.key);
  if (!handle) return new Response('Not Found', { status: 404 });
  return new Response(handle.body, {            // streams, never reads the file into memory
    headers: {
      'content-type': handle.contentType,
      'content-length': String(handle.size),
      'x-content-type-options': 'nosniff',
      'content-disposition': 'attachment',
    },
  });
}

Mint the signed URL where you render the link (a page or component):

import { signedUrl } from '@webjsdev/server';

const href = signedUrl(user.avatarKey, { secret: process.env.AUTH_SECRET!, expiresIn: 3600 });

Serving user uploads safely

The content-type a store records is the one the BROWSER sent at upload time, so it is attacker-controlled. A serving route that reflects it inline lets an attacker run script in your origin (stored XSS) by uploading HTML or image/svg+xml tagged text/html under an innocent-looking key. The serving route MUST send X-Content-Type-Options: nosniff, and SHOULD send Content-Disposition: attachment for anything a user uploaded (the recipe above does both). Only serve a user upload inline when you have validated the bytes server-side and emit a content-type from a strict inert allowlist (image/png, image/jpeg), never text/html / image/svg+xml. Serving uploads from a separate cookieless origin is the strongest mitigation.

File, Blob, and FormData round-trip the action serializer

A native File, Blob, or FormData passes through the server-action wire intact, so the same saveAvatar(file) call works whether it runs during SSR (the real function) or from a client component (an RPC stub). You never hand-write a multipart fetch. See Server Actions for the full list of rich types the wire round-trips.

S3-pluggability

The interface operates on web-standard objects only, so an S3 / R2 / GCS / MinIO adapter is a drop-in. It implements the same put (PutObject, streaming the body), get (GetObject, returning the SDK's response stream as body), delete (DeleteObject), and url (the object / CDN URL). Because the shape is identical, setFileStore(s3Store({ ... })) switches the whole app with no call-site change. WebJs ships no S3 SDK (no new dependency), so the adapter is a thin wrapper an app provides.

Next Steps

  • Server Actions: the RPC boundary that round-trips File / Blob / FormData
  • Route Handlers: the route.ts that streams stored bytes back
  • Caching: the cache adapter the file store's swap-the-backend model mirrors