# Plugins (https://v2.s3.dimah.dev/docs/server/plugins)

Use `definePlugin` to build reusable server extensions such as
[`db()`](https://v2.s3.dimah.dev/docs/db). A plugin can add hooks, HTTP endpoints under your
`basePath`, and a typed context on the instance (`s3[id]`).

```ts title="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:

```ts title="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], // [!code ++]
  routes: {
    avatar: route({ upload: true }),
  },
});

s3.audit.log("Storage ready"); // [!code ++]
```

`definePlugin`, `createS3Endpoint`, `createS3Middleware`, `isFeatureOn`, and
the [cleanup helpers](#cleaning-up-uploads) come from
`@dimah-s3/server/plugins`. `chainHooks` comes from the main entry.

---

## Hook execution order [#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](https://v2.s3.dimah.dev/docs/server/hooks/upload#failure-rules).

### Binding tickets [#binding-tickets]

A plugin can provide `hooks.ticketSubject` to bind
[upload tickets](https://v2.s3.dimah.dev/docs/server/upload-tickets#binding-a-ticket-to-a-user)
when the config sets none. The first plugin that has one is used. `db()`
binds tickets to the scope this way.

---

## Endpoints [#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:

```ts
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`](https://v2.s3.dimah.dev/docs/server/hooks/global-guard#locals)
augmentation.

---

## Cleaning up uploads [#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.

```ts
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 [#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)`:

```ts
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`:

```ts
avatar: route({
  upload: true,
  plugins: { audit: { label: "Avatars" } }, // [!code ++]
}),
```

---

## Type reference [#type-reference]

```ts
import type {
  DimahS3Plugin,
  DimahS3PluginHooks,
  PluginInitEnv,
  S3EndpointContext,
} from "@dimah-s3/server/plugins";
```

### DimahS3Plugin [#dimahs3plugin]

<AutoTypeTable
  path="packages/server/src/plugin/types.ts"
  name="DimahS3Plugin"
/>

---

### DimahS3PluginHooks [#dimahs3pluginhooks]

<AutoTypeTable
  path="packages/server/src/plugin/types.ts"
  name="DimahS3PluginHooks"
/>

---

### PluginInitEnv [#plugininitenv]

<AutoTypeTable
  path="packages/server/src/plugin/types.ts"
  name="PluginInitEnv"
/>

---

### S3EndpointContext [#s3endpointcontext]

<AutoTypeTable
  path="packages/server/src/api/context.ts"
  name="S3EndpointContext"
/>

## Frequently asked questions [#frequently-asked-questions]

<Accordions>

<Accordion title="How do I add client-side actions for a server plugin?">

Create a client plugin with `defineClientPlugin` from `@dimah-s3/core`. Use
`pluginPath` so the client and server paths match:

```ts
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 },
        }),
    }),
  });
}
```

</Accordion>

<Accordion title="Can a route opt out of a global plugin?">

Yes. Set `plugins: { [pluginId]: false }` in your route definition:

```ts
avatar: route({
  upload: true,
  plugins: { audit: false }, // [!code ++]
}),
```

</Accordion>

<Accordion title="How do I combine several guards in one slot?">

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:

```ts
import { chainHooks } from "@dimah-s3/server";

upload: {
  guard: chainHooks(requireUser, checkQuota), // [!code ++]
},
```

</Accordion>

</Accordions>
