# Write a custom upup plugin

A plugin is the smallest way to teach `@useupup/core` something new. Cloud
drives — Google Drive, OneDrive, Dropbox, Box — are plugins, and the contract
they satisfy is the same one open to you: subscribe to the engine's event bus,
emit your own events on it, and optionally hand back real `File` objects for
the pipeline to upload.

If you only want to switch the built-in drives on, you want
[Plugins & Extensions](/docs/guides/plugins/) instead. This page is for
authors.

## The plugin contract

A plugin is a name and one optional lifecycle hook:

```ts
interface UpupPlugin {
    name: string
    init?(emitter: EventEmitter): void
}
```

`name` is the identity used for deduplication; registering two plugins with the
same name throws a `UpupConfigError` with code `PLUGIN_ALREADY_REGISTERED`.

`init` is **the one lifecycle hook**. It is called once, at registration, and
what it receives is core's **event bus** — not the core itself. That is the
whole boundary: a plugin listens and emits. There is no `beforeUpload()`, and
no teardown callback on `UpupPlugin`.

Three consequences worth knowing before you write one:

- **A plugin cannot register an extension from `init`.** It never gets a
  reference to the core, so `core.registerExtension()` is the path — your
  consumer calls it after `use()`, or you ship a factory that closes over the
  core. Document the methods you expect to be registered.
- **A plugin has no `destroy` hook on the base contract.** `core.destroy()`
  clears the plugin registry, but it does not call anything on your plugin.
  (`DrivePlugin` adds a required `destroy()` because the drive controllers call
  it explicitly.) If you own a timer or a socket, hand your consumer a cleanup
  handle.
- **Payloads arrive untyped inside `init`.** Core hands the plugin its bus
  through the untyped `EventEmitter` signature, so a handler's payload is
  `unknown` even for a well-known event. Narrow it yourself — see below.

## A complete minimal plugin

An analytics listener, the canonical shape. Note the derived `Emitter` type:
the `EventEmitter` class itself is not on the public entry, so deriving the
parameter type from `UpupPlugin` keeps you on the public surface with no deep
import.

```ts
import type { UpupPlugin } from '@useupup/core'

type Emitter = Parameters<NonNullable<UpupPlugin['init']>>[0]

type Track = (event: string, props?: Record<string, unknown>) => void

export function analyticsPlugin(track: Track): UpupPlugin {
    return {
        name: 'analytics',

        init(emitter: Emitter) {
            emitter.on('upload-start', () => track('upload_started'))

            emitter.on('upload-success', payload => {
                // Payloads are `unknown` on the plugin-facing bus — narrow here.
                const { file } = payload as {
                    file: { name: string; size: number }
                }
                track('upload_succeeded', { name: file.name, size: file.size })
            })

            emitter.on('upload-error', payload => {
                const { error } = payload as { error: Error }
                track('upload_failed', { message: error.message })
            })
        },
    }
}
```

The full list of engine events you can subscribe to is the
[event catalog](/docs/api-reference/events/).

<Callout type="note" title="Your throws are contained">
    The emitter isolates each handler, so a throw inside your plugin cannot
    abort sibling listeners or escape `emit()`. In development it is logged to
    the console; it is never re-emitted as an upload failure. Handle your own
    errors — nobody downstream will see them.
</Callout>

## Registering

Your consumer registers the plugin, either imperatively — `use()` returns the
core, so it chains:

```ts
core.use(analyticsPlugin(track)).use(anotherPlugin())
```

…or through the `plugins` option, which is part of `CoreOptions` and therefore
available on the framework components and the headless hook as well:

```ts
const core = new UpupCore({
    uploadEndpoint: '/api/upload-token',
    plugins: [analyticsPlugin(track)],
})
```

Constructor-supplied plugins register in array order, before the first upload.
Each registration emits `plugin-registered` with your plugin's name.

## Naming rules

Two naming rules the engine enforces socially rather than at the type level, so
they are worth getting right the first time.

**Emit namespaced event names.** Anything your plugin emits reaches every
`core.on(...)` subscriber, so use a prefix you own (`'myplugin:ready'`). Bare
event names are the engine's own typed catalog, and adding to it is not
supported. In particular, a bare `error` is off limits: upup has exactly one
upload-failure event, `upload-error`, and there is deliberately no second
channel for it. A drive plugin's failures go out as `<prefix>:error`.

**Provider identity has exactly two forms.** If your plugin represents a
provider, pick one word and spell it two ways, never three:

| Form       | Looks like                  | Used for                                              |
| ---------- | --------------------------- | ----------------------------------------------------- |
| camelCase  | `googleDrive`, `oneDrive`   | config keys, `FileSource` values, i18n keys           |
| kebab-case | `google-drive`, `one-drive` | plugin ids, event prefixes, server slugs, DOM strings |

Single-word providers (`dropbox`, `box`) are identical in both. A
bare-concatenated slug like `googledrive` is a retired form in this codebase —
do not introduce one.

## Building a cloud-drive provider

Cloud drives are plugins that satisfy a richer contract, `DrivePlugin`, which
extends `UpupPlugin` with the surface upup's drive controllers drive.

Required members: `id`, `init(emitter)`, `destroy()`, `restoreSession()`,
`isAuthenticated()`, `getAccessToken()`, `getUserInfo()`, `loadFiles(folderArg?)`,
`downloadFiles(files)`, and `signOut()`. Optional, guarded at the call site:
`authenticate()`, `authenticateViaPopup()`, `loadMoreFiles(cursor)`,
`loadAllFilesInFolder(folderArg)`, and `getConfig()`.

`downloadFiles` is where a drive meets the rest of the engine: it returns real
`File` objects, which then flow through the normal pipeline and upload path.

### Subclassing `PopupOAuthPlugin`

If your provider uses browser popup OAuth2 with PKCE, don't hand-roll it.
`PopupOAuthPlugin` is an abstract base — exported as a value from
`@useupup/core` — that owns the whole auth skeleton: the PKCE challenge, opening
and polling the popup, the authorization-code exchange, `sessionStorage`
persistence, refresh-token exchange, proactive refresh when the access token is
within 60 seconds of expiry, and an `apiRequest` helper that retries once on a
401 before declaring the session expired.

You supply two things: a `PopupOAuthSpec` (pure data) and your provider's real
API calls.

```ts
import { PopupOAuthPlugin, type PopupOAuthSpec } from '@useupup/core'
import type { DriveFile, DriveUser } from '@useupup/core'

export class AcmeDrivePlugin extends PopupOAuthPlugin {
    readonly spec: PopupOAuthSpec = {
        id: 'acme-drive',
        displayName: 'Acme Drive',
        eventPrefix: 'acme-drive',
        popupName: 'UpupAcmeAuth',
        authUrl: 'https://acme.example.com/oauth2/authorize',
        tokenUrl: 'https://api.acme.example.com/oauth2/token',
        redirectPath: '/acme_redirect',
        storageKeys: {
            access: 'upup_acme_access_token',
            refresh: 'upup_acme_refresh_token',
            expiry: 'upup_acme_token_expiry',
        },
        scopes: 'files.read files.write profile.read',
        authParams: { access_type: 'offline' },
    }

    // ── List a folder. `apiRequest` adds auth and handles refresh for you. ──
    async loadFiles(folderId = 'root') {
        this.setState('browsing')

        const res = await this.apiRequest(
            `https://api.acme.example.com/folders/${folderId}/children`,
            { method: 'GET' },
        )
        const data = (await res.json()) as {
            items?: Record<string, unknown>[]
            next?: string
        }
        const files = (data.items ?? []).map(item => this.mapEntry(item))

        this.setState('authenticated')
        this.emitter?.emit('acme-drive:files-loaded', {
            files,
            path: folderId,
            hasMore: Boolean(data.next),
            cursor: data.next,
        })

        return { files, hasMore: Boolean(data.next), cursor: data.next }
    }

    // ── Fetch bytes and hand back real Files. ──
    async downloadFiles(driveFiles: DriveFile[]): Promise<File[]> {
        const out: File[] = []
        for (const driveFile of driveFiles) {
            if (driveFile.isFolder) continue
            const res = await this.apiRequest(
                `https://api.acme.example.com/files/${driveFile.id}/content`,
                { method: 'GET' },
            )
            const blob = await res.blob()
            out.push(
                new File([blob], driveFile.name, {
                    type: blob.type || driveFile.mimeType,
                }),
            )
        }
        return out
    }

    // ── Provider hooks the base requires. ──
    protected mapEntry(entry: Record<string, unknown>): DriveFile {
        const isFolder = entry.type === 'folder'
        return {
            id: String(entry.id ?? ''),
            name: String(entry.name ?? ''),
            path: String(entry.path ?? ''),
            size: isFolder ? 0 : Number(entry.size ?? 0),
            mimeType: isFolder ? 'folder' : String(entry.mime ?? ''),
            isFolder,
        }
    }

    protected async fetchUserProfile(): Promise<DriveUser> {
        const res = await this.apiRequest('https://api.acme.example.com/me', {
            method: 'GET',
        })
        const data = (await res.json()) as { name?: string; email?: string }
        return { name: data.name ?? '', email: data.email ?? '' }
    }
}
```

Five abstract members are what the base demands: `spec`, `loadFiles`,
`downloadFiles`, `mapEntry`, and `fetchUserProfile`. Everything else — auth
state, tokens, popup lifecycle — is inherited. `id` and `name` are both derived
from `spec.id`, so `spec.id` is what deduplicates the plugin and what
`core.getPlugin(...)` takes.

Add `loadMoreFiles(cursor)` if your API paginates. The `cursor` is opaque to
upup: encode whatever your API needs (a raw token, an offset, a next-link URL)
and it comes back to you verbatim.

Register it like any plugin, after handing it its credentials:

```ts
const acme = new AcmeDrivePlugin()
acme.configure({ clientId: process.env.NEXT_PUBLIC_ACME_CLIENT_ID! })
core.use(acme)
```

<Callout type="info" title="Google Drive is the exception">
    It uses Google Identity Services, which issues access tokens with no PKCE
    popup and no refresh token, so `GoogleDrivePlugin` is a standalone
    `implements DrivePlugin` rather than a subclass. If your provider's auth
    doesn't fit the popup skeleton, implement `DrivePlugin` directly — the base
    class is a convenience, not a requirement.
</Callout>

### The namespaced event surface

A drive plugin's events are namespaced by `spec.eventPrefix`. Six names, all
prefixed:

| Event                      | Payload                                                                 | When                                      |
| -------------------------- | ----------------------------------------------------------------------- | ----------------------------------------- |
| `<prefix>:state-change`    | `{ state }` — the new `DriveState`                                      | any auth/browse state transition          |
| `<prefix>:authenticated`   | `{ user }` — the `DriveUser`, or `undefined` if the profile call failed | token exchange succeeded                  |
| `<prefix>:files-loaded`    | files, path, `hasMore`, cursor                                          | a folder listing landed                   |
| `<prefix>:session-expired` | empty                                                                   | refresh failed or a 401 was unrecoverable |
| `<prefix>:error`           | the `Error` plus the failing `action`                                   | any operation failed                      |
| `<prefix>:signed-out`      | empty                                                                   | `signOut()` was called                    |

`DriveState` is one of `idle`, `authenticating`, `authenticated`, `browsing`,
or `session-expired`.

`PopupOAuthPlugin` emits all six for you — state changes go out through the
protected `setState()`, and auth, refresh, and sign-out are fully handled. Your
domain methods only need to emit `files-loaded` on a successful listing and
`error` on a failure, as the example above does.

Anyone with a core reference subscribes the normal way:

```ts
core.on('acme-drive:files-loaded', payload => {
    console.log(payload)
})
```

## What's public today

Worth stating plainly, because it decides your imports. All of these are named
exports of the **public `@useupup/core` entry** as of 3.1.0:

- `UpupPlugin`, `ExtensionMethods` (types)
- `DrivePlugin` (type)
- `PopupOAuthPlugin` (class, exported as a value) and `PopupOAuthSpec` (type)
- `DriveFile`, `DriveFolder`, `DriveUser`, `DriveState`, `DriveEventMap`,
  `DriveListPage`, `DriveBrowserError` (types)
- The four built-in providers — `GoogleDrivePlugin`, `OneDrivePlugin`,
  `DropboxPlugin`, `BoxPlugin` — and their config types

These are **not** on the public entry, which is what shaped the recipes above:

- `EventEmitter` — the reason the plugin example derives its `Emitter` type from
  `UpupPlugin` instead of importing the class.
- `PluginManager` — an implementation detail; you go through
  `core.use()` / `registerExtension()` / `getExtension()` / `getPlugin()`.
- `DriveProviderDescriptor` and `DriveBrowserController` — the machinery behind
  the shipped drive-browser UI. The four descriptor **constants**
  (`GOOGLE_DRIVE_DESCRIPTOR` and friends) are exported, but the descriptor type
  is not.

All three live behind `@useupup/core/internal`, a deep-import-only subpath. It is
importable, but it is explicitly not the stable surface — names there can change
in a minor release, so build against the public entry wherever you can.

<Callout type="warning" title="Your drive won't appear as a source chip">
    The shipped uploader wires exactly the four known providers from the
    `cloudDrives` option, and `FileSource` is a closed set. Your plugin
    registers and runs fine, and it is a first-class citizen in a headless UI
    you build yourself — but there is no registry today that adds a new
    provider to `<UpupUploader>`'s built-in source selector.
</Callout>

## Next steps

- [Plugins & Extensions](/docs/guides/plugins/) — the consumer side: enabling
  the built-in drives, and registering and calling what you wrote.
- [Headless Usage](/docs/guides/headless/) — build the UI your custom provider
  will live in.
- [Events](/docs/api-reference/events/) — the full typed core-event catalog your
  plugin subscribes to, and how namespaced events pass through.
- [Error Handling](/docs/error-handling/) — the `UpupError` taxonomy your plugin
  should throw from, including `UpupAuthError` and `UpupNetworkError`.
- [Client Mode vs Server Mode](/docs/guides/modes/) — plugins are a client-mode
  mechanism; here is what server mode does instead.
