Plugins
Extend dimahS3 with definePlugin — hooks, HTTP endpoints, ticket binding, and typed context.
Use definePlugin to build reusable server extensions such as
db(). A plugin can add hooks, HTTP endpoints under your
basePath, and a typed context on the instance (s3[id]).
import { createS3Endpoint, definePlugin } from "@dimah-s3/server/plugins";
import * as z from "zod";
export const auditPlugin = definePlugin({
id: "audit",
hooks: {
upload: {
onConfirmed: async ({ key, uploadToken }) => {
await audit.record({ key, uploadToken });
},
},
},
endpoints: {
recent: createS3Endpoint(
"/audit/recent",
{
method: "GET",
query: z.object({ limit: z.coerce.number().int().max(100).optional() }),
},
async (ctx) => ({
events: await audit.recent(ctx.context.locals, ctx.query.limit),
}),
),
},
context: {
log: (message: string) => console.log("[audit]", message),
},
});Register the plugin in your instance:
import { dimahS3, route } from "@dimah-s3/server";
import { auditPlugin } from "./s3-audit-plugin";
export const s3 = dimahS3({
client: awsS3,
bucket: process.env.S3_BUCKET!,
plugins: [auditPlugin],
routes: {
avatar: route({ upload: true }),
},
});
s3.audit.log("Storage ready"); definePlugin, createS3Endpoint, createS3Middleware, isFeatureOn, and
the cleanup helpers come from
@dimah-s3/server/plugins. chainHooks comes from the main entry.
Hook execution order
- Guards run instance, then route, then feature (
upload.guard,download.guard, …). At each level, plugin guards run first in plugin order, and your guard runs last. - Lifecycle hooks (
onStart,onConfirmed,onPresigned,onDeleted,onAbort) also run plugins first, then yours. Your hook sees state the plugins already wrote, such as thedb()row. - A route with
plugins: { [id]: false }skips that plugin's route hooks.
A rejection (4xx) from a plugin onConfirmed deletes the object and
returns the error: the plugin refused it. Any other throw, such as a
database outage, keeps the object so the client can retry, and a throw from
yours keeps it too. init must be synchronous: a returned promise throws
at dimahS3().
See Upload Hooks.
Binding tickets
A plugin can provide hooks.ticketSubject to bind
upload tickets
when the config sets none. The first plugin that has one is used. db()
binds tickets to the scope this way.
Endpoints
createS3Endpoint(path, options, handler) builds a plugin endpoint. The path
is under basePath and must not be a core path.
methodisGET,POST,PATCH, orDELETE.GETandDELETEread thequery.POSTandPATCHread a JSONbody; any other content type returns415 UNSUPPORTED_MEDIA_TYPE.- The instance guards run first, with
operation: "plugin". The handler readsctx.context.request,ctx.context.locals(what those guards returned), andctx.context.config. - A schema failure returns
400 VALIDATION_ERROR. AnAPIErrorpasses through. Anything else is logged, passed toonError, and returns500 INTERNAL_ERROR. - Endpoints are HTTP only. They are not added to
s3.api; expose server-side functions throughcontextinstead.
createS3Middleware builds a middleware with the same context, for checks
shared by several endpoints:
import { createS3Middleware, createS3Endpoint } from "@dimah-s3/server/plugins";
import { s3Error } from "@dimah-s3/server";
const requireUser = createS3Middleware(async (ctx) => {
if (!ctx.context.locals.userId) throw s3Error("UNAUTHORIZED");
});
createS3Endpoint(
"/audit/export",
{ method: "POST", use: [requireUser] },
async (ctx) => ({ url: await audit.export(ctx.context.locals) }),
);locals.userId is typed by a DimahS3Locals
augmentation.
Cleaning up uploads
A plugin that tracks uploads, like db(), may need to remove an object that
an upload left behind. Delete it only while it is still the object that
upload wrote: another upload may have replaced it at the same key since.
import { HeadObjectCommand } from "@aws-sdk/client-s3";
import {
deleteObjectIfMatch,
isAwsNotFound,
readUploadMarker,
} from "@dimah-s3/server/plugins";
async function removeUpload(client: S3Client, row: TrackedUpload) {
const head = await client
.send(new HeadObjectCommand({ Bucket: row.bucket, Key: row.key }))
.catch((error: unknown) => {
if (isAwsNotFound(error)) return null;
throw error;
});
if (!head || readUploadMarker(head.Metadata) !== row.uploadToken) return;
await deleteObjectIfMatch(client, {
bucket: row.bucket,
key: row.key,
eTag: head.ETag,
});
}readUploadMarker(metadata)returns theuploadTokenan object was written with, from HeadObjectMetadata. It is theuploadTokenyouronStartandonConfirmedhooks receive.deleteObjectIfMatch(client, { bucket, key, eTag })sendsIf-Match, so an object written after your HeadObject is kept and it returnsfalse. A provider without conditional deletes gets a plain delete. Other failures throw.isAwsNotFound(error)is true for a missing object or multipart upload, by error name,Code, or HTTP status.
Startup and route options
init runs once at dimahS3(), in plugin order, after the routes are
validated. Throw there to reject the config. dependsOn lists plugins that
must be registered too.
To read per-route options, augment DimahS3RoutePluginOptions with your id
and call routeOptions(route):
declare module "@dimah-s3/server" {
interface DimahS3RoutePluginOptions {
audit?: false | { label: string };
}
}
export const auditPlugin = definePlugin({
id: "audit",
init: ({ config, routeOptions, logger }) => {
for (const name of Object.keys(config.routes)) {
const options = routeOptions<{ label: string }>(name);
if (options === false) logger.info?.(`audit is off for ${name}`);
}
},
});Routes set them under plugins:
avatar: route({
upload: true,
plugins: { audit: { label: "Avatars" } },
}),Type reference
import type {
DimahS3Plugin,
DimahS3PluginHooks,
PluginInitEnv,
S3EndpointContext,
} from "@dimah-s3/server/plugins";DimahS3Plugin
Prop
Type
DimahS3PluginHooks
Prop
Type
PluginInitEnv
Prop
Type
S3EndpointContext
Prop
Type
Frequently asked questions
Create a client plugin with defineClientPlugin from @dimah-s3/core. Use
pluginPath so the client and server paths match:
import { defineClientPlugin, pluginPath } from "@dimah-s3/core";
export function auditClient() {
return defineClientPlugin({
id: "audit",
getActions: ($fetch) => ({
recent: (limit?: number) =>
$fetch(pluginPath("audit", "recent"), {
method: "GET",
query: { limit },
}),
}),
});
}Yes. Set plugins: { [pluginId]: false } in your route definition:
avatar: route({
upload: true,
plugins: { audit: false },
}),Use chainHooks. It runs each hook in order and stops at the first throw.
Each { locals } result is merged before the next hook runs, so chained
guards behave like separate ones:
import { chainHooks } from "@dimah-s3/server";
upload: {
guard: chainHooks(requireUser, checkQuota),
},