API
createBucket, upload, put, get, getUrl, delete, destroy.
The snapshot API is the one most applications use. The file API underneath it stays available for everything that is not a snapshot — attachments, exports, avatars.
createBucket(config?)
Returns a Bucket. Cheap: the underlying S3Client is built on the first request, so calling it
at module scope is fine. See Configuration for every option.
const store = createBucket({ bucket: 'my-bucket', region: 'eu-west-3' })new Bucket(config) is equivalent, and exported for when you want to subclass or type against it.
putSnapshot(key, data, options?)
Stores a snapshot of an application's local state. The value is wrapped in a self-describing envelope, serialized as JSON and gzipped. See Snapshots.
const result = await store.putSnapshot(code, state, { app: 'notes', version: 3, expiresIn: 3600 })| Option | Type | Default |
|---|---|---|
app | string | — stamped in the envelope |
version | number | — your schema version |
device | string | — free label for the writing device |
expiresIn | number (seconds) | — no expiry |
compress | boolean | true |
ifMatch | string | — write only if the stored ETag still matches |
ifAbsent | boolean | — write only if nothing is stored yet |
prefix | string | the bucket-level prefix |
signal | AbortSignal | — |
Returns { bucket, key, path, etag?, size?, compressed, createdAt, expiresAt? }. Keep the etag
when another device may write the same key — see two devices.
Throws INVALID_SNAPSHOT when the data is not JSON-serializable, and PRECONDITION_FAILED when
an ifMatch or ifAbsent write loses the race.
getSnapshot(key, options?)
Reads a snapshot back, or null when there is none — including one past its expiresAt, which is
never handed over even while the object is still in the bucket.
const snapshot = await store.getSnapshot<AppState>(code, { maxVersion: SCHEMA_VERSION })| Option | Type |
|---|---|
maxVersion | number — throws SNAPSHOT_TOO_NEW above it |
prefix | string |
signal | AbortSignal |
| Field | Type |
|---|---|
data | T — the generic you passed, unvalidated |
app, device | string | undefined |
version | number | undefined |
createdAt | Date |
expiresAt | Date | undefined |
etag, size | string / number, when S3 reports them |
bucket, key, path | string |
Compression is detected rather than assumed, so a snapshot written with compress: false reads
back the same way.
store.codes
The code scheme, in the shape syncCode configured. Generation and normalization come from the
same object, so they cannot disagree about the alphabet. See Sync codes.
store.codes.create() // "K7QP2M4X"
store.codes.normalize('k7-qp2m4x') // "K7QP2M4X"| Member | |
|---|---|
create() | A fresh code, generated with nanoid. |
normalize(input) | What someone typed, canonicalized. Throws INVALID_SYNC_CODE on anything the alphabet cannot contain. |
alphabet, length | What the scheme was configured with. |
entropyBits | What a code is worth guessing against — 40 for the default, 13.29 for four digits. |
createSyncCodes(options?)
Builds a scheme outside a store. Same object as store.codes.
import { createSyncCodes, syncCodeAlphabets } from 's3nd'
const codes = createSyncCodes({ length: 4, alphabet: syncCodeAlphabets.digits })| Option | Default |
|---|---|
length | 8, at most 64 |
alphabet | syncCodeAlphabets.crockford — at least two distinct characters, no spaces, dashes or underscores |
See Configuring codes for what each shape costs.
createSyncCode(options?) and normalizeSyncCode(input, options?)
The one-shot forms, on the default scheme unless you pass options. Prefer store.codes in
application code.
createSyncCode() // "K7QP2M4X"
normalizeSyncCode('k7-qp2m4x') // "K7QP2M4X"syncCodeAlphabets
crockford (the default), digits, and alphanumeric. Any string of your own works too.
upload(body, options?)
Uploads a body in a single PutObject. Writing to a key that already exists replaces it — S3 has
no separate update call.
const result = await store.upload(file, { prefix: 'invoices' })body accepts a string, Buffer, Uint8Array, ArrayBuffer, typed array, Blob, File,
Node Readable or web ReadableStream.
| Option | Type | Default |
|---|---|---|
key | string | generated: <uuid>-<filename> |
prefix | string | the bucket-level prefix |
filename | string | the File name, when there is one |
contentType | string | the File type, else guessed from the extension, else application/octet-stream |
contentLength | number | the body size — required for streams |
cacheControl | string | — |
contentDisposition | string | — |
metadata | Record<string, string> | — |
acl | ObjectCannedACL | — (most buckets block ACLs; prefer a bucket policy) |
ifMatch | string | — write only if the stored ETag still matches |
ifAbsent | boolean | — write only if nothing is stored yet |
signal | AbortSignal | — |
Returns:
{
bucket: string
key: string // the handle: pass it back to get/getUrl/delete
path: string // full object key in the bucket, prefix included
contentType: string
size?: number
etag?: string // quotes stripped
url?: string // only when `publicUrl` is configured
}When the body carries a filename — a File, or an explicit filename option — it is stored as
user metadata so get() can hand it back later.
put(id, body, options?)
upload(body, { key: id }), with the identifier first because that reads better when one
identifier owns one file. Same options minus key, same result.
await store.put('XK5892', file)Writing to an id that already holds a file replaces it.
get(key, options?)
Reads a stored file back. Returns null when the key does not exist. For a snapshot use
getSnapshot(), which unwraps the envelope for you.
const file = await store.get('XK5892')Accepts { signal } to abort.
| Field | Type |
|---|---|
bucket, key, path | string |
contentType | string |
filename | string | undefined — the name it was uploaded with |
size | number | undefined |
etag | string | undefined |
lastModified | Date | undefined |
metadata | Record<string, string> — raw S3 user metadata, keys lowercased |
body | Readable |
bytes() | Promise<Uint8Array> |
text() | Promise<string> |
The body can only be read once. Use body, bytes() or text() — exactly one of them.
getUrl(key, options?)
Returns the public URL when publicUrl is configured, a presigned GET otherwise. Signing is
local: no network round-trip, no extra S3 permission.
await store.getUrl('invoices/2026-01.pdf', { expiresIn: 300, download: 'Invoice January.pdf' })| Option | Type | Default |
|---|---|---|
expiresIn | number (seconds, max 604800) | 3600 |
signed | boolean | true unless publicUrl is set |
download | boolean | string | — sets Content-Disposition: attachment, with a filename when you pass a string |
download only applies to signed URLs: the disposition rides in the signed query string. For a
public URL, set contentDisposition at upload time instead.
delete(key)
await store.delete('uploads/a.png')
await store.delete(['uploads/a.png', 'uploads/b.png']) // batched, 1000 keys per requestDeleting a key that does not exist is a no-op, not an error — the same semantics as S3 itself.
Passing an array batches into DeleteObjects calls of 1000 keys; if S3 reports per-key failures,
delete() throws DELETE_FAILED naming them.
store.client
The underlying S3Client, created on first access. Use it for anything s3nd does not cover:
import { ListObjectsV2Command } from '@aws-sdk/client-s3'
const response = await store.client.send(new ListObjectsV2Command({ Bucket: store.bucket, Prefix: 'uploads/' }))store.destroy()
Releases the HTTP sockets of the client s3nd created. Optional in a long-lived server; useful
in a script that should exit promptly. A client you passed in through config.client is left
alone — it is yours to close.
S3ndError and isS3ndError(error)
See Errors.