@chattee.ai/blobs
v1.2.0
Published
File storage client for applications built on Chattee.
Readme
@chattee.ai/blobs
File storage for applications built on Chattee.
Container filesystems are rebuilt on every deploy, so anything written there is gone the next time the app ships. This stores files somewhere that survives — and, for public files, serves them from your app's own domain.
Setup
Nothing to configure. Adding the blob-storage resource to a project sets CHATTEE_API_URL,
CHATTEE_PROJECT_TOKEN and CHATTEE_BLOBS_MAX_MB in the backend's environment; you never create,
read or manage those.
import { blobs } from "@chattee.ai/blobs";Server-side only. This module holds a secret from the backend's environment. Importing it into browser code would ship that secret to every visitor. There is deliberately no browser build.
Public and private files
Public files get a stable URL on your app's own domain, so they work in <img src>, in an Open
Graph tag and in an email. Nothing you write sits on that path.
const { url } = await blobs.upload(fileStream, {
filename: "avatar.png",
visibility: "public",
});
// url === '/files/k3j2h1g0/avatar.png'Private files are not reachable from any URL — there is no signed link, no token parameter, and no way to make one. Your backend fetches the bytes and serves them after checking whatever it needs to check. The decision stays yours; only the storage is ours.
const { key } = await blobs.upload(pdfStream, { filename: "invoice.pdf" });
app.get("/invoices/:id", requireLogin, async (req, res) => {
const invoice = await db("invoices").where({ id: req.params.id }).first();
if (invoice.user_id !== req.user.id) return res.sendStatus(403);
const stream = await blobs.get(invoice.blob_key);
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
`attachment; filename*=UTF-8''${encodeURIComponent(invoice.filename)}`,
);
stream.pipe(res);
});get() gives you a Node stream, so .pipe() is the whole of it. Note the filename*=UTF-8'' form:
percent-encoding inside a plain quoted filename="..." is not decoded by browsers, so a file called
my report.pdf would save as my%20report.pdf.
If the transfer fails partway the stream emits error, and pipe does not pass that on to your
response — it is left open until something times out. Use pipeline(stream, res) from
node:stream/promises if you would rather the response were torn down.
Store the key in your own table alongside whatever it belongs to. The key is how you find the file
again; everything else about who may see it is your app's business.
API
| Method | |
| -------------------------- | ------------------------------------------------------------------- |
| upload(content, options) | store a file; returns { key, url, size, contentType, visibility } |
| get(key) | the bytes as a Node Readable, for your app to serve |
| getRange(key, range) | part of a file, so <video> and <audio> can seek |
| getBuffer(key) | the bytes as a Buffer, for small files |
| delete(key) | remove a file; false if it was not there |
| list() | every file and the project's total usage |
| publicUrl(key) | the URL of a public file |
| isConfigured() | whether storage is set up, for a graceful fallback |
| maxFileSizeBytes() | the per-file ceiling, so you can reject early |
upload(content, options)
content may be a stream, a Buffer, a Uint8Array or a string.
| Option | Default | |
| ------------- | ------------- | ------------------------------------------------- |
| filename | — | the original name; decides how the file is served |
| key | generated | your own key, if you want one |
| contentType | from filename | |
| visibility | 'private' | 'public' to get a URL |
| ownerUserId | — | your own user id, recorded with the file |
A generated key keeps the readable filename with a unique prefix, so two people uploading
photo.png do not collide.
getRange(key, range)
A browser cannot seek in <video> or <audio> unless the server answers 206, and it will not ask
unless your route passes its Range header on.
const { stream, status, headers } = await blobs.getRange(key, req.headers.range);
res.writeHead(status, {
"Content-Type": headers.contentType,
"Content-Length": headers.contentLength,
...(headers.contentRange && { "Content-Range": headers.contentRange }),
"Accept-Ranges": "bytes",
});
stream.pipe(res);status is 206 for a partial answer and 200 when the whole file came back — forward whichever
you were given rather than assuming.
Two things the platform does not give back
The original filename. It is used to work out how the file is served and is not returned by
get(). Keep it in your own table next to the key; that is where the rest of what you know about
the file already lives.
The content type you declared. The stored type is derived from the filename's extension,
never from what the uploader claimed — so list() can report something other than the
contentType you passed to upload(). Set your response's Content-Type from your own row, as
the example above does; that is your file and your users.
Errors
Everything throws ChatteeBlobError with a stable code:
| code | |
| ---------------- | ---------------------------------------------------------------------------------- |
| too_large | over this project's per-file limit — check maxFileSizeBytes() first to fail fast |
| storage_limit | the project has used its whole allowance |
| not_found | no such key |
| no_storage | file storage is not enabled for this project |
| unauthorized | the project token is missing or wrong |
| not_configured | no Chattee configuration in the environment at all |
| unreachable | the platform could not be reached |
import { ChatteeBlobError } from "@chattee.ai/blobs";
try {
await blobs.upload(stream, { filename });
} catch (err) {
if (err instanceof ChatteeBlobError && err.code === "too_large") {
return res.status(413).json({ error: "That file is too big." });
}
throw err;
}What is served inline
A public file's content type is derived from its extension by the platform, never from what the uploader claims. Images, PDFs, video and audio render in place; everything else downloads, including HTML and SVG. That is deliberate: files are served from your app's own domain, so a document that rendered in place could run script against your app with your users' sessions.
Install
npm install @chattee.ai/blobsRequirements
Node 18 or newer (uses the built-in fetch and stream.Readable.fromWeb), and a project with the
blob-storage managed resource.
Licence
MIT
Release notes
1.2.0
get(key) now returns a Node Readable, so stream.pipe(res) works. It used to return
fetch's WHATWG ReadableStream, which has no .pipe() — so the documented way to serve a private
file threw body.pipe is not a function and every private download answered 500. The documented
shape was always the intended one; this makes the code agree with it.
Nothing you wrote needs to change: code written against the documentation starts working, and
getBuffer(key) is unaffected. If you worked around this with Readable.fromWeb(...) of your own,
remove the wrapper — fromWeb rejects a Node stream. If you consumed the result with pipeTo() or
getReader(), those are web-stream methods and are gone; pipe(), pipeline() and for await
all work.
New: getRange(key, range). Pass a browser's Range header through and forward the 206 it
comes back with, so <video> and <audio> can seek in a private file. Previously the whole file
was the only thing you could serve.
The documentation says two things it should always have said: the original filename is not
returned by get() — keep it in your own table — and the stored content type is derived from the
filename's extension rather than from the contentType you passed.
1.1.0
Adds AGENT.md to the published package. It is the model-facing core of this README — the same
prose, without the install/requirements/licence/release-note sections — and it is what Chattee's
build agent is given when a project uses this resource. Publishing it means the copy in
node_modules always describes the version actually installed.
Nothing you wrote needs to change; there is no API change in this release.
1.0.1
CHATTEE_API_URL is now accepted with or without the /_chattee/v1 prefix. This client was
already correct — it treats the binding as carrying the prefix, which is what the platform sets. The
normaliser now sits in front of it so all three Chattee client packages agree on one rule, and a
future change to the binding cannot break the one resource that always worked.
Nothing you wrote needs to change.
1.0.0
First release: upload, get, delete, list, and publicUrl. Public files get a stable URL on the app's
own domain; private files are fetched by your backend and served under your own authorization.
