Overview
Wrap every operation on a Files instance in an ordered pipeline - transform, veto, or observe - and contribute new namespaced methods. The interceptable superset of hooks.
A hook can only watch an operation go by. A plugin can change it. Plugins are an opt-in, ordered pipeline you pass to the constructor; each one wraps every operation on the instance and can transform the inputs, veto the call, observe the result - or add entirely new methods.
import { createFiles, handlers } from "files-sdk";
import { s3 } from "files-sdk/s3";
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [
{
name: "uppercase",
wrap: handlers({
upload: (op, next) =>
next({ ...op, body: (op.body as string).toUpperCase() }),
}),
},
],
});
await files.upload("a.txt", "hello"); // stored as "HELLO"Reach for a plugin when you need to change behavior - envelope-encrypt bodies at rest, gate uploads through a virus scanner, meter bandwidth, mirror writes to a backup region. Keep hooks for lightweight, fire-and-forget observability; a plugin's wrap is the interceptable superset that can transform and veto where hooks only watch.
The two capabilities
A FilesPlugin is an object with a name and up to two optional capabilities. A plugin can use either or both.
| Capability | What it does | Changes the instance type? |
|---|---|---|
wrap | Intercept every operation - transform the inputs, veto by throwing, or wrap the result. | No |
extend | Contribute new namespaced methods (e.g. files.usage()). | Yes - via createFiles |
Because wrap doesn't touch the instance type, plugins that only wrap work with plain new Files({ plugins }). Only extend adds surface, and that's the one case createFiles exists for.
wrap: intercepting operations
wrap(op, next) receives the current operation and a next function that continues inward. Call next(op) to run the rest of the pipeline (and ultimately the real call); pass a modified op to transform it, return a modified result to rewrite the output, or throw to veto.
const plugin: FilesPlugin = {
name: "logger",
wrap: async (op, next) => {
console.log("→", op.kind);
const result = await next(op); // continue inward
console.log("←", op.kind);
return result;
},
};Plugins compose as ordered, nested layers: plugins[0] is the outermost. With [a, b], a write runs a → b → the real operation → b → a. A nice property falls out for free: because the innermost layer wraps the real read, read-side inverses self-order. Given [validate, compress, encrypt], a download unwinds decrypt → decompress → validate automatically - you never hand-manage the symmetry.
Where plugins sit
Plugins run inside the onAction / onError hooks but outside retries and key prefixing:
- A
wrapruns once per logical operation, not once per retry attempt. Encryption seals the body once; a retry resends the bytes the plugin already produced. - Plugins see caller-facing keys - never the internal prefixed path. A key-rewriting plugin rewrites before prefixing.
- The hooks still fire around the whole thing, so
onActionreports the final, plugin-produced result.
Bulk operations too
wrap intercepts both single and bulk calls. The array forms of upload, download, head, exists, and delete fan out to one operation per item, each carrying bulk: true so a plugin can tell a batch element from a standalone call. This means an encryption() plugin encrypts upload(key, body) and every item of upload([...]) - no silent plaintext footgun.
When any wrapping plugin is installed,
delete([...])fans out to per-key deletes through each plugin instead of the adapter's native batch primitive, so every key is intercepted. Without plugins, the native batch path is unchanged.
handlers(): per-verb wraps
A raw wrap is right for cross-cutting plugins that touch every verb (logging, metering, tracing). For transforms that only care about one or two operations, handlers lets you write a per-verb map - each handler is typed to its own operation, and any verb you don't list passes straight through:
import { handlers } from "files-sdk";
const encryption = (key: CryptoKey): FilesPlugin => ({
name: "encryption",
wrap: handlers({
// typed as the upload op; `next` is typed to the upload result
upload: (op, next) =>
seal(op.body, key).then(({ body, iv }) =>
next({
...op,
body,
options: { ...op.options, metadata: { ...op.options?.metadata, iv } },
})
),
download: (op, next) => next(op).then((file) => unseal(file, key)),
// head, exists, delete, copy, move, list, url, signedUploadUrl: untouched
}),
});You don't have to write this plugin yourself - we ship
encryption() out of the box.
extend: new methods
extend(files) returns an object of methods grafted onto the instance. It runs once at construction against the fully-wrapped instance, so an extension method that calls back into files.upload(...) also passes through every plugin.
const usage = (): FilesPlugin<{ usage: () => number }> => {
let bytes = 0;
return {
name: "usage",
wrap: async (op, next) => {
const result = await next(op);
if (op.kind === "upload") {
bytes += result.size;
}
return result;
},
extend: () => ({ usage: () => bytes }),
};
};An extension key that collides with an existing Files method, a getter, or another plugin's extension throws at construction rather than silently shadowing it - so a plugin can never quietly break upload or make the instance un-awaitable.
Typing extend with createFiles
new Files({ plugins }) works at runtime regardless, but a class constructor can't return this & Ext keyed off its arguments - so the extra methods won't show up on the type. createFiles is the seam that surfaces them. It's identical to new Files() at runtime; it just carries the plugins' extend return types onto the result.
import { createFiles } from "files-sdk";
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [usage()],
});
files.usage(); // ✅ typedThe built-in versioning() plugin is a real example of this - it uses extend to add files.versions() and files.restore(), so you construct it with createFiles.
Things to keep in mind
- Order is the contract.
[compress, encrypt]compresses then encrypts (encrypted bytes don't compress);[validate, scan, transform]fails fast before doing work. Document each plugin's place even though reads self-order. - Buffering transforms break streaming. Encrypt / compress / scan need the whole body in memory, which is incompatible with unknown-length streams and resumable uploads (which re-read the original body). Gate those plugins the way the core already gates streams.
- Metadata-stashing needs adapter support. A plugin that round-trips state through
options.metadata(an encryption IV, say) only works on adapters that support metadata - the same gate a directmetadataupload hits. wrapruns outside the timeout. Per-attempt timeouts bound the adapter call, not a slow plugin. The caller'soptions(includingsignal) ride on the operation, so a plugin can opt into cancellation itself.