@dimah-s3/core
The protocol, the fetch client, the error catalog, and pure helpers shared by the server and React packages.
@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 i @dimah-s3/core@betaWhat lives here
createS3Client— the fetch client for the HTTP API. It implementsS3Apiand 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.- 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 asuploadBodySchemaanddownloadQuerySchema. 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,
next to the other formatters.
Client
import { createS3Client } from "@dimah-s3/core";
export const api = createS3Client({
basePath: "/api/s3",
credentials: "include",
headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
});basePathmust match the serverbasePath(default/api/s3).baseURLis the absolute API URL, such ashttps://api.example.com/api/s3. It wins overbasePath.headersis an object or a function called before each request.fetchreplaces the globalfetch, 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
Every method takes a payload, then optional { signal, headers }. Per-call
headers override the client headers:
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, andtype.nameandtypeare at most 255 UTF-8 bytes, with no control characters. - Confirm and every multipart call after init send only the upload ticket. The server picks the multipart part layout.
- Send a presigned PUT with exactly the returned
headers. They are signed. - In proxy mode,
downloadreturns a/fileURL and noexpiresIn. With an absolutebaseURL, the client makes that URL absolute too.
The result also has $fetch (the shared fetch for plugin calls),
$ERROR_CODES, and $Infer.
Errors
Every failure rejects with an APIError that has a catalog code:
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.
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 |
GETandDELETEread a query. Every other endpoint reads a JSON body and acceptsapplication/jsononly.fileis the file proxy that proxy-mode downloads point to. It is not anS3Apimethod.deleteManytakes at mostDELETE_MANY_MAX_KEYS(100) keys.
To build your own backend for the React hooks, implement S3Api. See
Custom Backend.
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:
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 for the server side.
Helpers
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
import type {
CreateS3ClientOptions,
S3Api,
S3RequestOptions,
UploadPresignResponse,
ConfirmedObjectResponse,
DownloadPresignResponse,
MultipartInitResponse,
RouteCatalogEntry,
} from "@dimah-s3/core";S3RequestOptions
Prop
Type
S3ClientFetchOptions
Prop
Type
ConfirmedObjectResponse
What confirm and multipart.complete return. Every value comes from
HeadObject.
Prop
Type
DownloadPresignResponse
Prop
Type
MultipartInitResponse
Prop
Type
MultipartPartResponse
Prop
Type
ResolveInlineDispositionInput
Prop
Type
Frequently asked questions
Yes. createS3Client works in any JavaScript runtime with fetch. You run
the upload steps yourself: presign, send the bytes to S3, then confirm.
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,
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.
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.