{"_id":"@buns3/sdk","_rev":"3-cdd143cec0137109eb17fea5c187f534","name":"@buns3/sdk","dist-tags":{"latest":"0.2.2"},"versions":{"0.2.0":{"name":"@buns3/sdk","version":"0.2.0","keywords":["buns3","object-storage","s3","presigned-url","client","sdk"],"author":{"name":"Sebastian Vallin"},"license":"MIT","_id":"@buns3/sdk@0.2.0","maintainers":[{"name":"sebbev","email":"sebastian.vallin@protonmail.com"}],"homepage":"https://github.com/buns3/buns3/tree/main/packages/sdk#readme","bugs":{"url":"https://github.com/buns3/buns3/issues"},"dist":{"shasum":"5ff7485c1fc9536a2b018754f40b352d389d400d","tarball":"https://registry.npmjs.org/@buns3/sdk/-/sdk-0.2.0.tgz","fileCount":7,"integrity":"sha512-sCq4T19twGdv1QUzHqE6UepxcTUn7+fC16KudGgUZ4w3q3NAm+EeCooDm9ZybD1ElscfoNKtOVo8RZJ2lGRQWg==","signatures":[{"sig":"MEYCIQCOPq+thkrMBb7/GZcXKU2YDjgsy2CwxOZZDx2XZDnbJgIhAOpZWtDaccYe7yTyDQt/waC+iuE1lVGWVzwmHLIXOrue","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":75197},"type":"module","module":"src/index.ts","engines":{"node":">=20"},"exports":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"gitHead":"e7d1a9b42df7edd2010c2e8887dbfb266659f1a9","scripts":{"dev":"tsdown --watch","build":"tsdown"},"_npmUser":{"name":"sebbev","email":"sebastian.vallin@protonmail.com"},"repository":{"url":"git+https://github.com/buns3/buns3.git","type":"git","directory":"packages/sdk"},"_npmVersion":"11.6.2","description":"TypeScript client for buns3 object storage, with offline presigned URLs. Runtime-agnostic, zero dependencies.","directories":{},"sideEffects":false,"_nodeVersion":"24.12.0","_hasShrinkwrap":false,"devDependencies":{"tsdown":"0.22.14","@types/bun":"catalog:dev","typescript":"catalog:dev"},"_npmOperationalInternal":{"tmp":"tmp/sdk_0.2.0_1788816544399_0.27762121281816765","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"name":"@buns3/sdk","version":"0.2.1","keywords":["buns3","object-storage","s3","presigned-url","client","sdk"],"author":{"name":"Sebastian Vallin"},"license":"MIT","_id":"@buns3/sdk@0.2.1","maintainers":[{"name":"sebbev","email":"sebastian.vallin@protonmail.com"}],"homepage":"https://github.com/buns3/buns3/tree/main/packages/sdk#readme","bugs":{"url":"https://github.com/buns3/buns3/issues"},"dist":{"shasum":"26174de239b66d716e64434068879c16f1c965c2","tarball":"https://registry.npmjs.org/@buns3/sdk/-/sdk-0.2.1.tgz","fileCount":7,"integrity":"sha512-+LE9Lb+b285ssCjlaHkV1bQ/35sebyDtuJ857e2j3q3ZTqMWJftur+P/q0pnA9NxWcIgP1mofR5K3Scqzrng5g==","signatures":[{"sig":"MEUCIQCu0/CAu2LrVktrDdh3hrVSyJLHzOp/mfUXt7uBS5/fVwIgcsqekN/z5h+Nkh++Vat85KAD7dlxN/PZ75VSQfFaSoY=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":77126},"type":"module","module":"src/index.ts","engines":{"node":">=20"},"exports":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"gitHead":"2dbf6906917bdd25db9229d9baa31b5283bfe84b","scripts":{"dev":"tsdown --watch","build":"tsdown"},"_npmUser":{"name":"sebbev","email":"sebastian.vallin@protonmail.com"},"repository":{"url":"git+https://github.com/buns3/buns3.git","type":"git","directory":"packages/sdk"},"_npmVersion":"11.6.2","description":"TypeScript client for buns3 object storage, with offline presigned URLs. Runtime-agnostic, zero dependencies.","directories":{},"sideEffects":false,"_nodeVersion":"24.12.0","_hasShrinkwrap":false,"devDependencies":{"tsdown":"0.22.14","@types/bun":"catalog:dev","typescript":"catalog:dev"},"_npmOperationalInternal":{"tmp":"tmp/sdk_0.2.1_1788816827730_0.9829313229405605","host":"s3://npm-registry-packages-npm-production"}},"0.2.2":{"name":"@buns3/sdk","version":"0.2.2","module":"src/index.ts","license":"MIT","type":"module","description":"TypeScript client for buns3 object storage, with offline presigned URLs. Runtime-agnostic, zero dependencies.","keywords":["buns3","object-storage","s3","presigned-url","client","sdk"],"homepage":"https://github.com/buns3/buns3/tree/main/packages/sdk#readme","repository":{"type":"git","url":"git+https://github.com/buns3/buns3.git","directory":"packages/sdk"},"bugs":{"url":"https://github.com/buns3/buns3/issues"},"author":{"name":"Sebastian Vallin"},"sideEffects":false,"engines":{"node":">=20"},"exports":{"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"},"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"scripts":{"dev":"tsdown --watch","build":"tsdown"},"devDependencies":{"tsdown":"0.22.14","@types/bun":"catalog:dev","typescript":"catalog:dev"},"gitHead":"bdd5e624fdd8a21275ad3ab7707487f43b606f14","_id":"@buns3/sdk@0.2.2","_nodeVersion":"24.12.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-F50VCSK7BdtNueFS/FcSDMIWcOaSPno84WgQiOLstXTwrxp1AjY6QG63oPoVUpxYS4Zuw4E36dSRtICmbu0S1A==","shasum":"8f0b68ca07edd048dd196aa3a0c92d452c28bb11","tarball":"https://registry.npmjs.org/@buns3/sdk/-/sdk-0.2.2.tgz","fileCount":7,"unpackedSize":78580,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQDxb+Qisj+kCwlWkSqouYzyfIeAwxN818eUHsrrH7c5IwIhAKE+szWBLK4yPPmX5p1R0Dj3sEEOT4dEGYnKGNM06csU"}]},"_npmUser":{"name":"sebbev","email":"sebastian.vallin@protonmail.com"},"directories":{},"maintainers":[{"name":"sebbev","email":"sebastian.vallin@protonmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/sdk_0.2.2_1788819563761_0.39339129608100465"},"_hasShrinkwrap":false}},"time":{"created":"2026-09-07T21:29:04.205Z","modified":"2026-09-07T22:19:24.058Z","0.2.0":"2026-09-07T21:29:04.533Z","0.2.1":"2026-09-07T21:33:47.860Z","0.2.2":"2026-09-07T22:19:23.888Z"},"bugs":{"url":"https://github.com/buns3/buns3/issues"},"author":{"name":"Sebastian Vallin"},"license":"MIT","homepage":"https://github.com/buns3/buns3/tree/main/packages/sdk#readme","keywords":["buns3","object-storage","s3","presigned-url","client","sdk"],"repository":{"type":"git","url":"git+https://github.com/buns3/buns3.git","directory":"packages/sdk"},"description":"TypeScript client for buns3 object storage, with offline presigned URLs. Runtime-agnostic, zero dependencies.","maintainers":[{"name":"sebbev","email":"sebastian.vallin@protonmail.com"}],"readme":"# @buns3/sdk\n\nThe TypeScript client for [buns3](https://github.com/buns3/buns3). Objects,\nbuckets, keys, and presigned URLs you can mint offline.\n\n**The \"bun\" in the name is the server, not the client.** This package needs\n`fetch` and WebCrypto and nothing else, so it runs on Node 20+, Deno, Bun, in\nbrowsers, and in edge runtimes. No runtime dependencies, and a test enforces\nthat.\n\n```bash\nnpm install @buns3/sdk\n# or: bun add / pnpm add / yarn add\n```\n\n## Getting started\n\n```ts\nimport { Buns3Client } from \"@buns3/sdk\";\n\nconst client = new Buns3Client(\"https://buns3.example.com\", { token });\n\nconst res = await client.objects.put(\"photos\", \"cat.jpg\", file, {\n  contentType: \"image/jpeg\",\n});\n\nif (res.success) {\n  console.log(res.data.location); // /photos/cat.jpg\n} else {\n  console.error(res.status, res.code);\n}\n```\n\nNothing throws. Every call returns `{ success: true, data }` or\n`{ success: false, status, code, detail? }`. Network failures come back the\nsame way, as `status: 0` with `code: \"NETWORK_ERROR\"`.\n\n## Two clients\n\nNo key works on both planes. An admin key can't touch objects; a data key\ncan't touch `/_admin`. So there are two clients.\n\n```ts\nconst client = new Buns3Client(baseUrl, { token }); // objects, presign\nconst adminClient = new Buns3AdminClient(baseUrl, { token }); // buckets, keys\n```\n\nBoth carry `self` (any valid key may introspect or revoke itself) and\n`presigned` (following a presigned URL needs no credentials at all). The data\nclient's token is optional — without one you can still read public buckets and\nfollow presigned URLs. The admin client requires one, because the admin routes\nhave no anonymous path.\n\n`baseUrl` must be an origin. Presigned URLs are assembled by concatenation, so\na base path would not survive.\n\n## Objects\n\n```ts\nawait client.objects.put(bucket, key, body, { contentType });\nawait client.objects.get(bucket, key, { anonymous });\nawait client.objects.head(bucket, key);\nawait client.objects.delete(bucket, key);\nawait client.objects.list(bucket, { prefix, after, limit });\nawait client.objects.deleteMany(bucket, keys);\n```\n\n`body` is anything `fetch` accepts: a string, `Blob`, `ArrayBuffer`, typed\narray, or `ReadableStream`. A `Content-Type` always goes out — yours, else a\n`Blob`'s own type, else `application/octet-stream`. The server stores whatever\nit's given and never sniffs, so it's worth getting right. `blob.stream()` drops\nthe type, so streamed uploads should pass one.\n\n`get` hands back the raw `Response`, so streaming, range requests and\ncancellation stay available. `head` returns parsed metadata instead, since\nwith no body the headers are the whole answer.\n\nListing is keyset-paginated. Pass the previous page's `nextAfter` as `after`;\n`null` means you've reached the end.\n\n```ts\nlet after: string | undefined;\ndo {\n  const page = await client.objects.list(\"photos\", { after, limit: 100 });\n  if (!page.success) break;\n  for (const object of page.data.objects) console.log(object.key);\n  after = page.data.nextAfter ?? undefined;\n} while (after);\n```\n\n`deleteMany` reports per-key results in request order. Keys that were already\ngone come back as `KEY_NOT_FOUND` items rather than failing the batch, so a\nteardown racing another delete still finishes.\n\nIf you're only working in one bucket, `bucket()` applies it once:\n\n```ts\nconst photos = client.bucket(\"photos\");\n\nawait photos.put(\"cat.jpg\", file);\nawait photos.list({ prefix: \"2026/\" });\n```\n\nSame six methods, delegating to the same code. It's a convenience rather than\na scope: a data key is already bucket-scoped server-side, but tokens are opaque\nso the SDK can't read the bucket from one, and global data keys are planned.\n\n## Large files\n\nA proxy usually caps request bodies well below the server's own limit —\nCloudflare at 100 MB — so a large file goes up in pieces:\n\n```ts\nawait client.objects.putChunked(bucket, key, file, {\n  contentType: \"video/mp4\",\n  chunkSize: 8 * 1024 ** 2,\n  onProgress: (uploaded, total) => console.log(uploaded / total),\n});\n```\n\nIt opens a session, appends the file in chunks and completes, answering\nexactly what `put` answers — the two are interchangeable to a caller.\n\nThe body must be a `Blob`, which a browser `File` and `Bun.file(path)` both\nare. Slicing is what makes a chunk retryable and what gives the loop its\nbound; a `ReadableStream` can only be read once, so resuming after a failed\nchunk would be impossible. Wrap other sources yourself: `new Blob([buffer])`.\n\nEach chunk states the offset the server last recorded, and the server refuses\none that disagrees. A failed chunk stops the upload and returns the server's\nown error — the session survives, so the same bytes can be resumed rather\nthan resent. Sessions nobody touches are collected after a day.\n\nThe plane underneath is there when you want the steps:\n\n```ts\nconst { data } = await client.uploads.create({ bucket, key, contentType });\nconst { uploadId } = data.upload;\n\nawait client.uploads.append(uploadId, chunk, offset);\nawait client.uploads.get(uploadId); // how far did it get?\nawait client.uploads.complete(uploadId);\nawait client.uploads.abort(uploadId);\n```\n\nA session can also be presigned, which is how a browser uploads without ever\nholding a key:\n\n```ts\nconst { data } = await client.uploads.presign(uploadId, 3600);\n// hand data.url to the browser: it carries every chunk and the completion\n```\n\nOne URL for the whole upload, because order and offset are the server's\nbusiness rather than the client's. It does not carry `abort`: throwing the\nsession away is destructive, and whoever opened it can do that with their own\nkey.\n\n## Presigned URLs\n\nTwo ways to mint one.\n\n```ts\n// Offline: no network, just the token and WebCrypto.\nconst { url, expires } = await client.presign({\n  method: \"GET\",\n  bucket: \"photos\",\n  key: \"cat.jpg\",\n  ttl: 900,\n});\n\n// Server-validated: one round trip, checked before signing.\nconst res = await client.self.presign({ ... });\n```\n\nOffline signing costs nothing and works from a worker with no server in reach.\nIt's also blind: a key that lacks the capability still produces a well-formed\nURL, which fails when someone follows it. `self.presign()` asks the server\nfirst, so a wrong-bucket key fails at mint time with\n`API_KEY_SCOPE_MISMATCH` rather than later.\n\nBoth return the same shape. `ttl` is a duration in seconds; `expires` is the\nabsolute timestamp it produced.\n\nTo follow a URL you were handed:\n\n```ts\nawait client.presigned.get(url);\nawait client.presigned.put(url, body);\n```\n\nThese send no credentials. The URL already carries its own, and presenting\nboth is rejected. There's no `presigned.list()`: a signature covers one method,\nbucket, key and expiry, so a URL can't authorize enumeration or a batch.\n\n## Admin\n\n```ts\nconst { buckets, keys } = adminClient.admin;\n\nawait buckets.list();\nawait buckets.create(name);\nawait buckets.update(name, { publicRead: true });\nawait buckets.delete(name);\n\nawait keys.list();\nawait keys.create({ name, bucketName, canRead, canWrite, isAdmin });\nawait keys.delete(id);\n```\n\nDestructuring works because the planes are closures, not methods.\n\n`keys.create` is the only response that carries a token, and it appears once —\nthe server keeps a hash. Listings return a `tokenHint`, enough to recognise a\nkey and not enough to use it.\n\nThe `CreateApiKeyOptions` union mirrors the server's schema, so an admin key\nwith a bucket, or a data key claiming `isAdmin`, won't compile. The remaining\nrule — a data key needs at least one of `canRead`/`canWrite` — isn't\nexpressible in TypeScript and comes back as a 422.\n\n## Errors\n\n`code` is a string union covering everything the server can return, plus the\nclient-only `NETWORK_ERROR`:\n\n```ts\nconst res = await client.objects.get(\"photos\", \"cat.jpg\");\nif (!res.success) {\n  switch (res.code) {\n    case \"KEY_NOT_FOUND\":\n      return null;\n    case \"INVALID_API_KEY\":\n      return refreshCredentials();\n    default:\n      throw new Error(`${res.status} ${res.code}`);\n  }\n}\n```\n\n`status` is always the transport status, even when the response body disagrees.\nA response the SDK can't parse as a buns3 error — a proxy's HTML, an empty\nbody, a code from a newer server — becomes `UNKNOWN` with the raw body in\n`detail`, so an older SDK degrades instead of lying.\n\n## Retries\n\nOn by default: three attempts, exponential backoff with full jitter, capped at\ntwo seconds. Network errors and `502`/`503`/`504`/`429` are retried; a\n`Retry-After` header is honored exactly.\n\n```ts\nnew Buns3Client(baseUrl, { token, retry: { attempts: 5 } });\nnew Buns3Client(baseUrl, { token, retry: false });\n```\n\n`500` is not retried — those are server bugs carrying a correlation ref, not a\ncondition that improves by asking again. A request with a `ReadableStream` body\nis never retried either: the stream is consumed by the first attempt, and a\nretry would silently upload nothing.\n\n## Decisions\n\n**Results, not exceptions.** Every failure is a return value, so HTTP errors,\nunparseable responses and dead networks are all handled in one place. There's\nno path where a missing `try` takes down the caller.\n\n**The SDK never gates on capability.** It won't check whether your key may\nread a bucket, whether a TTL is under the cap, or whether a batch fits in 1000\nkeys. The server owns those rules and answers 422 or 403. A client that\nsecond-guesses authorization eventually disagrees with the server, and the\nclient is the one that's wrong. Not offering what provably can't work is a\ndifferent thing, which is why the admin surface isn't on the data client.\n\n**One encoder per layer.** Path segments are encoded by the route builder,\nquery values by `URLSearchParams`, and callers always pass raw values.\nEncoding twice produces `%2520` and a key that can't be found; the rule is\nthat the layer writing the URL does the encoding, once.\n\n**Timestamps stay strings.** `createdAt` and `lastModified` come through\nexactly as sent. Reviving them to `Date` would invent a type the protocol\ndoesn't have, and anyone who wanted the string back would have to undo it.\n\n**Zero runtime dependencies.** An SDK's dependencies become its consumers'\ndependencies: version conflicts, install size, supply-chain surface. A test\nscans every source file and fails on any non-relative import. A stray one\ntypechecks fine and only turns up in the bundle, which is how 147 kB of\nvalidation library once got in.\n\n**The offline signer is a port, not a reimplementation.** It produces\nbyte-identical signatures to the server's, pinned by frozen vectors and by an\nanchor hash the server's own tests have asserted since before this package\nexisted. Two implementations of one signature stay honest only if something\ncompares them.\n\n## Development\n\n```bash\nbun test              # 257 tests, ~90ms, all fakes — no server needed\nbun x tsc --noEmit    # source\nbun x tsc --noEmit -p tsconfig.test.json   # tests (Bun types live here)\nbun run build         # tsdown -> dist/, dual ESM + CJS\n```\n\nRun the build before believing a change is finished: it's the only thing that\ncatches an import that resolved through the workspace but wouldn't resolve for\na consumer.\n\nMIT.\n","readmeFilename":"README.md"}