# Server Setup (https://v2.s3.dimah.dev/docs/server/setup)

## Install [#install]

```npm
npm i @dimah-s3/server@beta @aws-sdk/client-s3
```

## Set the ticket secret [#set-the-ticket-secret]

Presign returns an [upload ticket](https://v2.s3.dimah.dev/docs/server/upload-tickets) that the
server signs with a secret. Set `DIMAH_S3_SECRET` to a random value of at
least 32 characters, the same on every instance:

```bash title=".env"
# openssl rand -base64 32
DIMAH_S3_SECRET="…"
```

`dimahS3()` reads it on the first upload. You can pass `secret` instead. When
neither is set, uploads return `500 INTERNAL_ERROR`, unless `NODE_ENV` is
`development` or `test`: then a random secret is used and a warning is
logged.

## Configure S3 and routes [#configure-s3-and-routes]

Pass your `@aws-sdk/client-s3` instance to `dimahS3` and define named routes.

Operations (`upload`, `download`, `delete`, and `upload.multipart`) are off by
default. Turn on only what each route needs.

```ts title="lib/s3.ts"
import { S3Client } from "@aws-sdk/client-s3";
import { dimahS3, route } from "@dimah-s3/server";

export const awsS3 = new S3Client({
  region: process.env.S3_REGION,
  endpoint: process.env.S3_ENDPOINT,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID!,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
  },
  requestChecksumCalculation: "WHEN_REQUIRED", // [!code ++]
});

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  routes: {
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024, // 2MB
      },
      download: true,
      delete: true,
    }),
  },
});
```

<Callout type="warn" title="requestChecksumCalculation">
  Recent AWS SDK versions default to `"WHEN_SUPPORTED"`, which adds checksum
  parameters that a presigned browser upload cannot match. The server checks
  each client before its first presign. Any value other than `"WHEN_REQUIRED"`
  is logged as a config error and the request returns `500 INTERNAL_ERROR`.
</Callout>

<Callout title="Uploading more than 5 GiB">
  A single presigned PUT or POST cannot exceed **5 GiB**. Larger files return
  `FILE_TOO_LARGE`. Turn on `upload.multipart` for them. The route's
  `maxFileSize` still applies.
</Callout>

---

## Mount route handler [#mount-route-handler]

Adapters are available for all major JavaScript runtimes:

<Tabs items={["Next.js", "Express", "Hono", "SvelteKit", "Fastify", "Elysia", "Node", "Web"]}>

<Tab value="Next.js">

```ts title="app/api/s3/[...s3]/route.ts"
import { toNextJsHandler } from "@dimah-s3/server/next";
import { s3 } from "@/lib/s3";

export const { GET, POST, PUT, PATCH, DELETE } = toNextJsHandler(s3);
```

<Callout title="Cache Components">
  Next.js 16.4 enables Cache Components on new apps. This handler reads the
  request, so presign, download, and delete run on each request. Leave the
  handler uncached. A `use cache` wrapper would reuse one presign across
  requests.
</Callout>

</Tab>

<Tab value="Express">

```ts title="server.ts"
import express from "express";
import { toExpressHandler } from "@dimah-s3/server/express";
import { s3 } from "./s3";

const app = express();
// The adapter reads the raw stream, so mount it before JSON middleware.
app.use("/api/s3", toExpressHandler(s3));
app.use(express.json());
```

A mount (`app.use`) and a route (`app.all("/api/s3/*", …)`, or
`"/api/s3/*splat"` on Express 5) both work: the adapter reads
`req.originalUrl`, which keeps the mount path.

</Tab>

<Tab value="Hono">

```ts title="src/index.ts"
import { Hono } from "hono";
import { toHonoHandler } from "@dimah-s3/server/hono";
import { s3 } from "./s3";

const app = new Hono();
app.on(
  ["GET", "POST", "PUT", "PATCH", "DELETE"],
  "/api/s3/*",
  toHonoHandler(s3),
);
```

</Tab>

<Tab value="SvelteKit">

```ts title="src/routes/api/s3/[...path]/+server.ts"
import { toSvelteKitHandler } from "@dimah-s3/server/svelte-kit";
import { s3 } from "$lib/s3";

const handler = toSvelteKitHandler(s3);
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
```

</Tab>

<Tab value="Fastify">

```ts title="server.ts"
import Fastify from "fastify";
import { toFastifyHandler } from "@dimah-s3/server/fastify";
import { s3 } from "./s3";

const app = Fastify();

app.register(async (scope) => {
  // Leave the body unread: the adapter streams it from the raw request.
  scope.removeAllContentTypeParsers();
  scope.addContentTypeParser("*", (_request, _payload, done) => done(null));
  scope.all("/api/s3/*", toFastifyHandler(s3));
});
```

Fastify parses JSON bodies before the handler runs. Turn parsing off for the
dimah-s3 routes, as above, so the adapter can read the body.

</Tab>

<Tab value="Elysia">

```ts title="src/index.ts"
import { Elysia } from "elysia";
import { toElysiaHandler } from "@dimah-s3/server/elysia";
import { s3 } from "./s3";

new Elysia().all("/api/s3/*", toElysiaHandler(s3)).listen(3000);
```

</Tab>

<Tab value="Node">

```ts title="server.ts"
import { createServer } from "node:http";
import { toNodeHandler } from "@dimah-s3/server/node";
import { s3 } from "./s3";

const handler = toNodeHandler(s3);

createServer((req, res) => {
  if (req.url?.startsWith("/api/s3")) return handler(req, res);
  res.statusCode = 404;
  res.end("Not found");
}).listen(3000);
```

</Tab>

<Tab value="Web">

```ts
export default {
  fetch(request: Request) {
    const url = new URL(request.url);
    if (url.pathname.startsWith("/api/s3")) {
      return s3.handler(request);
    }
    return new Response("Not found", { status: 404 });
  },
};
```

</Tab>

</Tabs>

### Node, Express, and Fastify [#node-express-and-fastify]

These adapters stream the request body from `req`, so the body cap is counted
as bytes arrive, and stream response bodies such as a [`/file`](https://v2.s3.dimah.dev/docs/server/file-proxy)
download to `res`. When the client disconnects before the response finishes,
the request signal aborts, and so does the S3 call behind it.

Behind a proxy that sets `X-Forwarded-Proto` and `X-Forwarded-Host`, pass
`trustProxy` so the request URL uses them:

```ts
toNodeHandler(s3, { trustProxy: true });
toExpressHandler(s3, { trustProxy: true });
toFastifyHandler(s3, { trustProxy: true });
```

Turn it on only behind a proxy that sets those headers. Clients can send them
too.

---

## Server-side API (`s3.api`) [#server-side-api-s3api]

`s3.api` has the same methods and payloads as the browser client, and runs
them in process with the same guards and hooks. The last argument is
`{ headers, signal, request, locals }`; `signal` aborts the call and its S3
requests, like the browser option:

```ts title="app/actions.ts"
"use server";

import { headers } from "next/headers";
import { s3 } from "@/lib/s3";

export async function getAvatarDownloadUrl(key: string) {
  return s3.api.download(
    { route: "avatar", key },
    { headers: await headers() }, // [!code ++]
  );
}
```

- **`headers`** — Copied onto a synthetic `GET` request to `baseURL`, or to
  `http://localhost` plus `basePath`. Guards read cookies and auth headers
  from it. The request has no query or body.
- **`request`** — The request hooks see. Pass the incoming one when you have
  it; it is used as is, and `headers` is ignored.
- **`locals`** — Trusted values that seed [`locals`](https://v2.s3.dimah.dev/docs/server/hooks/global-guard#locals)
  before any guard runs. HTTP requests can never set them.

```ts title="jobs/cleanup.ts"
await s3.api.deleteMany(
  { route: "files", keys },
  { locals: { userId: job.ownerId } }, // [!code ++]
);
```

Plugin endpoints, such as the `db()` list, are HTTP only. Plugins expose their
server-side API on the instance instead (`s3.db`).

## Server-side uploads (`s3.put`) [#server-side-uploads-s3put]

`s3.put` uploads from the server through the route's upload policy. It runs
`upload.object`, `upload.guard`, `upload.onStart` (`kind: "server"`), the
PutObject, and `upload.onConfirmed`. `confirmGuard` does not run.

```ts
const object = await s3.put(
  {
    route: "avatar",
    name: "profile.png",
    type: "image/png",
    body: imageBuffer,
  },
  { headers: await headers() }, // [!code ++]
);
// object: { key, bucket, name, size, type, eTag, metadata, … }
```

`body` can be a `Blob`, a `ReadableStream` (pass `size` with it), a
`Uint8Array`, an `ArrayBuffer`, or a string. The object gets a
`Content-Disposition` with the file name. `metadata` is written to S3 on top
of `upload.object().metadata`. `clientMetadata` reaches the hooks and is never
written.

---

## Type reference [#type-reference]

```ts
import type {
  DimahS3Config,
  PutInput,
  S3ServerCallOptions,
} from "@dimah-s3/server";
```

### DimahS3Config [#dimahs3config]

<AutoTypeTable
  path="packages/server/src/types/config.ts"
  name="DimahS3Config"
/>

---

### S3ServerCallOptions [#s3servercalloptions]

<AutoTypeTable
  path="packages/server/src/types/config.ts"
  name="S3ServerCallOptions"
/>

---

### PutInput [#putinput]

<AutoTypeTable path="packages/server/src/types/config.ts" name="PutInput" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>

<Accordion title="How do error handling and status codes work?">

Every failure is an `APIError` with a stable `code` and an HTTP status:

- Throw `s3Error("FORBIDDEN")`, or another catalog code, to reject a call.
- A plain `Error` thrown anywhere, guards included, is logged, passed to
  `onError`, and returns `500 INTERNAL_ERROR`.

See [Errors](https://v2.s3.dimah.dev/docs/server/errors) for the full list of error codes.

</Accordion>

<Accordion title="How does key generation and prefix isolation work?">

By default, dimah-s3 isolates object keys by route prefix (the route name):

```
avatar/{uuid}/{name}
```

Upload generates that key. Download and delete accept a client-sent key only
if it stays under the same prefix; otherwise they return `KEY_INVALID`. That
check is always in `@dimah-s3/server`. [`db()`](https://v2.s3.dimah.dev/docs/db) adds per-user
scope, not the prefix bound.

Setting `keyPrefix: false` disables that namespace boundary and requires strict
application guards. See
[Security](https://v2.s3.dimah.dev/docs/server/security#route-namespace-vs-ownership).

To customize the folder or key structure, use `upload.object` in your route definition.

</Accordion>

<Accordion title="How do I change the base path or support cross-origin APIs?">

Set `basePath` when initializing `dimahS3`, or `baseURL` when the API has its
own origin. With `baseURL`, proxy download URLs are absolute, and its path
must equal `basePath` when you set both:

```ts
export const s3 = dimahS3({
  basePath: "/api/storage", // [!code ++]
  // ...
});
```

Make sure the client specifies the same `basePath` (or full `baseURL` if hosted on a separate domain):

```ts
export const s3Client = createS3Client({
  baseURL: "https://api.example.com/api/storage", // [!code ++]
});
```

</Accordion>

<Accordion title="How large can a request body be?">

`maxRequestBodySize` caps every request body, 1 MiB by default. It is counted
on the stream before parsing, so a chunked body cannot get around it. Going
over returns `413 REQUEST_TOO_LARGE`. Body endpoints accept
`application/json` only and return `415 UNSUPPORTED_MEDIA_TYPE` for anything
else.

</Accordion>

</Accordions>
