# @dimah-s3/core (https://v2.s3.dimah.dev/docs/core)

`@dimah-s3/core` is the protocol source of truth. `@dimah-s3/server`
implements it, and `@dimah-s3/react` calls it. It has no React and no AWS SDK
code, so it runs in the browser, on the server, and in any Fetch runtime.

```npm
npm i @dimah-s3/core@beta
```

## What lives here [#what-lives-here]

- **`createS3Client`** — the fetch client for the HTTP API. It implements `S3Api` and takes client plugins.
- **`S3_API_ROUTES` / `S3_API_BASE_PATH`** — the endpoint paths.
- **`S3_ERROR_CODES`, `APIError`, `s3Error`, `isS3ErrorCode`** — the error catalog and its helpers. See [Errors](https://v2.s3.dimah.dev/docs/server/errors).
- **Limits** — `S3_MAX_POST_OBJECT_BYTES`, `DELETE_MANY_MAX_KEYS`, `ROUTE_NAME_PATTERN`, and the other protocol and S3 limits.
- **Zod schemas** — in `@dimah-s3/core/schema`: the request bodies and queries the server validates, such as `uploadBodySchema` and `downloadQuerySchema`. They live outside the root entry, so browser bundles never include Zod.
- **Client plugin helpers** — `defineClientPlugin`, `createS3Fetch`, `pluginPath`.
- **Pure helpers** — `validateFile`, `sha256Blob`, `resolveInlineDisposition`, `fitFileName`, and more.

The React package does not re-export any of this. Import it from
`@dimah-s3/core`. Display formatters such as `formatFileSize` and
`truncateFileName` live in [`@dimah-s3/react/engine`](https://v2.s3.dimah.dev/docs/react/guides/engine),
next to the other formatters.

---

## Client [#client]

```ts title="lib/s3-client.ts"
import { createS3Client } from "@dimah-s3/core";

export const api = createS3Client({
  basePath: "/api/s3",
  credentials: "include",
  headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
});
```

- `basePath` must match the server `basePath` (default `/api/s3`).
- `baseURL` is the absolute API URL, such as
  `https://api.example.com/api/s3`. It wins over `basePath`.
- `headers` is an object or a function called before each request.
- `fetch` replaces the global `fetch`, for tests or SSR.

For React apps, use `createS3Client` from `@dimah-s3/react`. It takes the same
options and adds a bound `Provider` and `useApi`.

### Calls [#calls]

Every method takes a payload, then optional `{ signal, headers }`. Per-call
headers override the client headers:

```ts
const controller = new AbortController();

const presign = await api.upload(
  { route: "avatar", name: file.name, size: file.size, type: file.type },
  { signal: controller.signal, headers: { "X-Request-Id": requestId } },
);
```

| Method                | Payload                                              | Returns                                                   |
| --------------------- | ---------------------------------------------------- | --------------------------------------------------------- |
| `upload`              | `{ route, name, size, type?, metadata?, checksum? }` | URL, `method`, `headers` or `fields`, `ticket`            |
| `confirm`             | `{ ticket }`                                         | The verified object                                       |
| `download`            | `{ route, key, name?, disposition?, expiresIn? }`    | `url`, `mode`, `disposition`, `expiresIn?`                |
| `catalog`             | none                                                 | Every route's public policy                               |
| `delete`              | `{ route, key }`                                     | `{ key, bucket }`                                         |
| `deleteMany`          | `{ route, keys }`                                    | `{ deleted, failed }`                                     |
| `multipart.init`      | `{ route, name, size, type?, metadata? }`            | `uploadId`, `ticket`, `partSize`, `partCount`, `checksum` |
| `multipart.signPart`  | `{ ticket, partNumber, checksum? }`                  | Part URL, `size`, `headers`                               |
| `multipart.listParts` | `{ ticket }`                                         | `{ parts }`                                               |
| `multipart.complete`  | `{ ticket }`                                         | The verified object and `uploadId`                        |
| `multipart.abort`     | `{ ticket }`                                         | `{ key, uploadId }`                                       |

- File fields are always `name`, `size`, and `type`. `name` and `type` are at
  most 255 UTF-8 bytes, with no control characters.
- Confirm and every multipart call after init send only the
  [upload ticket](https://v2.s3.dimah.dev/docs/server/upload-tickets). The server picks the
  multipart part layout.
- Send a presigned PUT with exactly the returned `headers`. They are signed.
- In proxy mode, `download` returns a `/file` URL and no `expiresIn`. With an
  absolute `baseURL`, the client makes that URL absolute too.

The result also has `$fetch` (the shared fetch for plugin calls),
`$ERROR_CODES`, and `$Infer`.

### Errors [#errors]

Every failure rejects with an `APIError` that has a catalog `code`:

```ts
import { isS3ErrorCode } from "@dimah-s3/core";

try {
  await api.confirm({ ticket });
} catch (error) {
  if (isS3ErrorCode(error, "UPLOAD_TICKET_EXPIRED")) {
    // Start the upload again
  }
}
```

A JSON error body keeps its `code`, `params`, and status. A non-JSON error
becomes `INTERNAL_ERROR` (status 500 or above) or `UNKNOWN_ERROR`, and a
request with no response becomes `NETWORK_ERROR`. An aborted call rejects
with the signal's reason. The full list is on [Errors](https://v2.s3.dimah.dev/docs/server/errors).

---

## Protocol [#protocol]

Paths are relative to `basePath`:

| Key                  | Method | Path                          |
| -------------------- | ------ | ----------------------------- |
| `upload`             | POST   | `/presign/upload`             |
| `confirm`            | POST   | `/presign/upload/confirm`     |
| `download`           | GET    | `/presign/download`           |
| `file`               | GET    | `/file`                       |
| `catalog`            | GET    | `/routes`                     |
| `delete`             | DELETE | `/delete`                     |
| `deleteMany`         | POST   | `/delete/batch`               |
| `multipartInit`      | POST   | `/presign/multipart/init`     |
| `multipartSignPart`  | POST   | `/presign/multipart/part`     |
| `multipartListParts` | POST   | `/presign/multipart/parts`    |
| `multipartComplete`  | POST   | `/presign/multipart/complete` |
| `multipartAbort`     | POST   | `/presign/multipart/abort`    |

- `GET` and `DELETE` read a query. Every other endpoint reads a JSON body and
  accepts `application/json` only.
- `file` is the [file proxy](https://v2.s3.dimah.dev/docs/server/file-proxy) that proxy-mode
  downloads point to. It is not an `S3Api` method.
- `deleteMany` takes at most `DELETE_MANY_MAX_KEYS` (100) keys.

To build your own backend for the React hooks, implement `S3Api`. See
[Custom Backend](https://v2.s3.dimah.dev/docs/react/custom-backend).

---

## Client plugins [#client-plugins]

A client plugin adds methods that call a server plugin's endpoints through
the shared `$fetch`. `pluginPath` builds the same path the server mounts:

```ts
import { defineClientPlugin, pluginPath } from "@dimah-s3/core";

export function auditClient() {
  return defineClientPlugin({
    id: "audit",
    getActions: ($fetch) => ({
      recent: (limit?: number) =>
        $fetch<{ events: unknown[] }>(pluginPath("audit", "recent"), {
          method: "GET",
          query: { limit },
        }),
    }),
  });
}

const api = createS3Client({ plugins: [auditClient()] });
await api.audit.recent(20);
```

The id becomes `api[id]`. Core method names such as `upload` and `delete` are
reserved (`RESERVED_CLIENT_KEYS`). `dbClient()` from `@dimah-s3/db/client` is
a client plugin. See [Plugins](https://v2.s3.dimah.dev/docs/server/plugins) for the server side.

---

## Helpers [#helpers]

```ts
import {
  buildPublicObjectUrl,
  resolveInlineDisposition,
  sha256Blob,
  validateFile,
} from "@dimah-s3/core";

// null when valid, otherwise an APIError:
// FILE_TYPE_NOT_ALLOWED, FILE_EMPTY, or FILE_TOO_LARGE
const error = validateFile(file, {
  accept: ["image/*"],
  maxFileSize: 2 * 1024 * 1024,
});

// SHA-256 as unpadded base64, read in 1 MiB chunks
const checksum = await sha256Blob(file, { signal });

// "inline" only when the type is on the allow-list
const { disposition } = resolveInlineDisposition({
  contentType: "image/png",
  requested: "inline",
});

// Public URL for public-read objects or a CDN. It does not sign.
const url = buildPublicObjectUrl({
  baseUrl: "https://cdn.example.com",
  key: "avatar/uuid/avatar.png",
});
```

| Helper                                                     | Does                                                                         |
| ---------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `validateFile`, `matchesFileTypes`                         | Checks a file against `accept` tokens and a size limit                       |
| `sha256Blob`, `sha256Base64`                               | SHA-256 in the form S3 and the upload `checksum` expect                      |
| `resolveInlineDisposition`, `DEFAULT_INLINE_TYPES`         | The server's inline rule. Pass the catalog `download.inlineTypes` as `allow` |
| `sniffContentType`, `matchesMagicBytes`, `isSniffableType` | Reads a type from the first bytes, as `verifyContent` does                   |
| `buildContentDisposition`, `parseFileName`                 | Builds and reads `Content-Disposition` with Unicode names                    |
| `buildObjectKey`, `normalizeObjectKey`                     | Joins key segments, and rejects unsafe keys (`..`, `\`, NUL)                 |
| `objectKeyByteLength`, `S3_MAX_OBJECT_KEY_BYTES`           | A key's UTF-8 length, and the 1024-byte limit `normalizeObjectKey` enforces  |
| `fitFileName`, `S3_MAX_FILE_FIELD_BYTES`                   | Fits a file name to the 255-byte wire rule                                   |
| `buildPublicObjectUrl`                                     | Joins a public origin and an encoded key                                     |
| `base64UrlEncode`, `base64UrlDecode`                       | base64url without `Buffer`                                                   |

`DEFAULT_INLINE_TYPES` holds raster images, PDF, audio, video, and
`text/plain`. A wildcard never matches HTML, XML (including SVG and
`text/xsl`), or JavaScript.

`fitFileName(name)` removes control characters, trims, and cuts a longer name
to 255 UTF-8 bytes on a character boundary. It keeps an extension of up to 32
bytes and falls back to `"file"`. The React upload engine applies it before
presign, so a long name uploads under a shortened name.

`sniffContentType` looks past a leading ID3v2 tag, so ID3-tagged FLAC sniffs
as `audio/flac`.

---

## Type reference [#type-reference]

```ts
import type {
  CreateS3ClientOptions,
  S3Api,
  S3RequestOptions,
  UploadPresignResponse,
  ConfirmedObjectResponse,
  DownloadPresignResponse,
  MultipartInitResponse,
  RouteCatalogEntry,
} from "@dimah-s3/core";
```

### S3RequestOptions [#s3requestoptions]

<AutoTypeTable
  path="packages/core/src/types/requests.ts"
  name="S3RequestOptions"
/>

---

### S3ClientFetchOptions [#s3clientfetchoptions]

<AutoTypeTable
  path="packages/core/src/plugin/types.ts"
  name="S3ClientFetchOptions"
/>

---

### ConfirmedObjectResponse [#confirmedobjectresponse]

What `confirm` and `multipart.complete` return. Every value comes from
HeadObject.

<AutoTypeTable
  path="packages/core/src/types/responses.ts"
  name="ConfirmedObjectResponse"
/>

---

### DownloadPresignResponse [#downloadpresignresponse]

<AutoTypeTable
  path="packages/core/src/types/responses.ts"
  name="DownloadPresignResponse"
/>

---

### MultipartInitResponse [#multipartinitresponse]

<AutoTypeTable
  path="packages/core/src/types/responses.ts"
  name="MultipartInitResponse"
/>

---

### MultipartPartResponse [#multipartpartresponse]

<AutoTypeTable
  path="packages/core/src/types/responses.ts"
  name="MultipartPartResponse"
/>

---

### ResolveInlineDispositionInput [#resolveinlinedispositioninput]

<AutoTypeTable
  path="packages/core/src/helpers/resolve-inline-disposition.ts"
  name="ResolveInlineDispositionInput"
/>

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

<Accordions>

<Accordion title="Can I call the API without the React package?">

Yes. `createS3Client` works in any JavaScript runtime with `fetch`. You run
the upload steps yourself: presign, send the bytes to S3, then confirm.

```ts
const presign = await api.upload({
  route: "avatar",
  name: file.name,
  size: file.size,
  type: file.type,
});

if (presign.method === "PUT") {
  const response = await fetch(presign.url, {
    method: "PUT",
    headers: presign.headers, // [!code ++]
    body: file,
  });
  if (!response.ok) throw new Error(`S3 returned ${response.status}`);
}

const object = await api.confirm({ ticket: presign.ticket });
```

When the route sets `checksum`, send `checksum: await sha256Blob(file)` with
the presign. `GET /routes` (`api.catalog()`) reports it as
`upload.checksum`.

</Accordion>

<Accordion title="I am upgrading from 1.7. What changed?">

File fields are renamed to `name` / `size` / `type`, confirm and multipart
calls take a `ticket`, and several route keys and errors are renamed. See
[Upgrade to 2.0 beta](https://v2.s3.dimah.dev/docs/migration-2-0).

</Accordion>

</Accordions>
