2.0 beta — new releases can include breaking changes. Stable 1.x docs
dimah-s3v2.0.0-beta.0

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]).

lib/s3-audit-plugin.ts
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:

lib/s3.ts
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 the db() 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.

  • method is GET, POST, PATCH, or DELETE. GET and DELETE read the query. POST and PATCH read a JSON body; any other content type returns 415 UNSUPPORTED_MEDIA_TYPE.
  • The instance guards run first, with operation: "plugin". The handler reads ctx.context.request, ctx.context.locals (what those guards returned), and ctx.context.config.
  • A schema failure returns 400 VALIDATION_ERROR. An APIError passes through. Anything else is logged, passed to onError, and returns 500 INTERNAL_ERROR.
  • Endpoints are HTTP only. They are not added to s3.api; expose server-side functions through context instead.

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 the uploadToken an object was written with, from HeadObject Metadata. It is the uploadToken your onStart and onConfirmed hooks receive.
  • deleteObjectIfMatch(client, { bucket, key, eTag }) sends If-Match, so an object written after your HeadObject is kept and it returns false. 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

On this page