s3nd

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 })
OptionTypeDefault
appstring— stamped in the envelope
versionnumber— your schema version
devicestring— free label for the writing device
expiresInnumber (seconds)— no expiry
compressbooleantrue
ifMatchstring— write only if the stored ETag still matches
ifAbsentboolean— write only if nothing is stored yet
prefixstringthe bucket-level prefix
signalAbortSignal

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 })
OptionType
maxVersionnumber — throws SNAPSHOT_TOO_NEW above it
prefixstring
signalAbortSignal
FieldType
dataT — the generic you passed, unvalidated
app, devicestring | undefined
versionnumber | undefined
createdAtDate
expiresAtDate | undefined
etag, sizestring / number, when S3 reports them
bucket, key, pathstring

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, lengthWhat the scheme was configured with.
entropyBitsWhat 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 })
OptionDefault
length8, at most 64
alphabetsyncCodeAlphabets.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.

OptionTypeDefault
keystringgenerated: <uuid>-<filename>
prefixstringthe bucket-level prefix
filenamestringthe File name, when there is one
contentTypestringthe File type, else guessed from the extension, else application/octet-stream
contentLengthnumberthe body size — required for streams
cacheControlstring
contentDispositionstring
metadataRecord<string, string>
aclObjectCannedACL— (most buckets block ACLs; prefer a bucket policy)
ifMatchstring— write only if the stored ETag still matches
ifAbsentboolean— write only if nothing is stored yet
signalAbortSignal

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.

FieldType
bucket, key, pathstring
contentTypestring
filenamestring | undefined — the name it was uploaded with
sizenumber | undefined
etagstring | undefined
lastModifiedDate | undefined
metadataRecord<string, string> — raw S3 user metadata, keys lowercased
bodyReadable
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' })
OptionTypeDefault
expiresInnumber (seconds, max 604800)3600
signedbooleantrue unless publicUrl is set
downloadboolean | 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 request

Deleting 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.

On this page