npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@constructorfleet/extension-sdk

v1.0.1

Published

Types and helpers for building Seerr extensions.

Readme

@constructorfleet/extension-sdk

Types and the defineExtension helper for building Seerr extensions.

This package contains no runtime coupling to Seerr. At runtime an extension receives an ExtensionSdk object built by the host, backed by the host's own Express router, TypeORM connection and winston logger. This package describes that object's shape and narrows it to what your manifest declares.

Install

npm install --save-dev @constructorfleet/extension-sdk

A dev dependency, not a dependency: nothing here executes. Its own type dependencies (express, typeorm, winston, zod) are peer dependencies, because at runtime the extension shares the host's copies — a second installed TypeORM would give you a Repository type that is nominally different from the one the SDK actually hands over.

The package version tracks the host API version, so ^1.0.0 here and "apiVersion": "^1.0.0" in your manifest mean the same thing.

An extension

Two files, at minimum.

seerr-extension.json at the package root:

{
  "id": "watch-history",
  "name": "Watch History",
  "version": "1.0.0",
  "apiVersion": "^1.0.0",
  "server": "dist/server.js",
  "requires": {
    "store": true,
    "media": "read"
  },
  "provides": {
    "permissions": [{ "key": "view_own", "name": "View Own History", "default": true }]
  }
}

And the entry point named by server:

import { defineExtension } from '@constructorfleet/extension-sdk';
import type { ExtensionManifest } from '@constructorfleet/extension-sdk';
import { WatchEvent } from './WatchEvent';

// Must agree with `seerr-extension.json`; see below for why it is not imported.
const manifest = {
  id: 'watch-history',
  name: 'Watch History',
  version: '1.0.0',
  apiVersion: '^1.0.0',
  server: 'dist/server.js',
  requires: { store: true, media: 'read' },
  provides: {
    permissions: [{ key: 'view_own', name: 'View Own History', default: true }],
  },
} as const satisfies ExtensionManifest;

export = defineExtension({
  manifest,
  entities: [WatchEvent],
  setup(sdk) {
    // `store` and `media` are non-optional here, because the manifest declares them.
    sdk.router.get('/history', { permission: 'view_own' }, async (req, res) => {
      const events = await sdk.store.getRepository(WatchEvent).find({
        where: { userId: req.user!.id },
      });

      res.json(events);
    });

    sdk.events.on('media.available', async ({ media }) => {
      sdk.logger.info(`${media.tmdbId} became available`);
    });
  },
});

export =, not export default. The loader reads module.exports.default for the setup function and module.exports.entities for the entities; export default defineExtension(...) nests both one level too deep and puts an object where the loader looks for a function.

Do not import manifest from '../seerr-extension.json', tempting as it is. resolveJsonModule widens as it infers: a JSON true becomes boolean, "read" becomes string, and an array becomes T[] rather than a tuple. Every conditional in DeclaredCapability then fails to match, so an imported JSON manifest narrows nothingsdk.store stays ExtensionStore | undefined and you are back to sdk.store!. Worse, it does so silently: nothing errors, you just lose the guarantee you came for.

So the manifest is written twice, and as const satisfies ExtensionManifest is what makes the copy narrow — satisfies rather than a : ExtensionManifest annotation, which would widen the literal types the narrowing reads, and as const so nested values stay literal. Keeping the two in agreement is then your job; the reference extension does it with a test that asserts the built literal is deep-equal to the JSON the host reads (examples/watch-history in the Seerr repository).

What defineExtension infers

The gated members of ExtensionSdk are optional in the host contract, because the loader attaches only the ones your manifest asked for. Given the manifest as a literal type, defineExtension recovers the guarantee:

| Manifest | Effect on sdk | | ---------------------------------------------- | ----------------------------------- | | requires.store: true | sdk.store is non-optional | | requires.jobs: true | sdk.jobs is non-optional | | requires.users: 'read' \| 'write' | sdk.users is non-optional | | requires.media: 'read' | sdk.media is non-optional | | requires.media: 'write' | …and gains sdk.media.remove | | requires.requests: 'read' \| 'write' | sdk.requests is non-optional | | requires.settings: 'read' | sdk.settings is non-optional | | provides.notifications with ≥ 1 entry | sdk.notify is non-optional | | anything not declared | absent from the type |

id, logger, router and events are always present.

requires.react is the one key in that block with no effect on sdk, because it asks for no capability — it is a compatibility assertion, like apiVersion one level down:

"requires": { "react": "^19.0.0" }

Declare it if you ship panels. Panels are handed the host's React, deliberately: a second copy breaks hooks. So a panel built against a different major receives the host's anyway and fails somewhere that never mentions React. With the range declared, a host outside it refuses the extension at install and quarantines it at discovery, naming both versions. Omit it if you ship no panels.

Undeclared capabilities are removed rather than left optional on purpose. An optional member turns a forgotten requires.users into a silent sdk.users?.get(id) that never runs; an absent one makes it a compile error.

media is the one capability whose access level matters

requires.media: 'read' gives you lookups — get and findByTmdbId. 'write' gives you those plus remove:

// requires: { media: 'write' }
await sdk.media.remove(mediaId, is4k);

remove deletes the media from the Radarr or Sonarr server it was added to, marks it — and, for a series, every season — deleted, and saves the row. Seerr keeps and owns that code: it is the same path DELETE /api/v1/media/:id/file runs. Your extension asks for the removal; it does not talk to Radarr or Sonarr, and it never gets a repository for Seerr's Media.

Under 'read', sdk.media.remove is a compile error rather than a possibly-undefined value, because the host genuinely does not attach it.

Two failures are worth distinguishing:

try {
  await sdk.media.remove(mediaId, is4k);
} catch (e) {
  if (e && typeof e === 'object' && 'arrName' in e) {
    // No such Radarr/Sonarr server is configured. Nothing was deleted, and the
    // operator has to fix their settings — retrying will not help.
  } else {
    // The media does not exist, or the Radarr/Sonarr call failed.
  }
}

The first is Seerr's NoServarrServerError, propagated unwrapped so you can tell it apart. This package cannot export the class (it must never import from the host), hence the structural check on arrName.

users and requests also accept 'read' | 'write', but neither has a write member yet, so for those the level is currently declaration only.

What it does not infer

  • Nothing about your keys. sdk.jobs.register('sync', fn) is not checked against provides.jobs[].id, and a route's permission: 'view_own' is not checked against provides.permissions[].key. Both are enforced at runtime — the loader throws on an undeclared job id, and notify.send throws on an undeclared notification key. Threading those literal unions through the types would mean parameterizing the whole ExtensionSdk on the manifest, for a check the host already makes.
  • Nothing from http, version, apiVersion or id.
  • Nothing at all from a widened manifest. If you write const manifest: ExtensionManifest = { ... }, the literal types are erased and no capability is inferred. Use satisfies ExtensionManifest, or pass the object straight to defineExtension, or import the JSON.

Entity and migration rules

Every table an extension owns must be named ext_<your-id>_*. Migrations run against a connection whose query runner rejects statements touching anything outside that prefix, and they are tracked in their own ext_<your-id>_migration table rather than in Seerr's.

entities and migrations are read at discovery, before the database connection is initialized, which is why they are properties of the module rather than something setup registers. TypeORM cannot register an entity after initialize().

For small state — a cursor, a last-run timestamp — use sdk.store.kv instead of declaring a table.

Date columns need DbAwareColumn

Seerr runs on sqlite or Postgres, and they disagree about date types: sqlite has datetime, Postgres wants timestamp with time zone. A bare @Column({ type: 'datetime' }) therefore works on your dev box and fails on an operator's Postgres deployment — a bug you cannot reproduce. Use the SDK's helper, which resolves the type the same way Seerr's core entities do:

import { DbAwareColumn, resolveColumnType } from '@constructorfleet/extension-sdk';
import { Entity, UpdateDateColumn } from 'typeorm';

@Entity({ name: 'ext_my-extension_thing' })
export class Thing {
  @DbAwareColumn({ type: 'datetime' })
  public happenedAt: Date;

  // `@UpdateDateColumn` and `@CreateDateColumn` take a resolved type directly,
  // since `DbAwareColumn` cannot wrap them.
  @UpdateDateColumn({ type: resolveColumnType('datetime') })
  public updatedAt: Date;
}

If a migration creates the column in raw SQL, it must make the same decision, or the migrated schema and the entity metadata will disagree:

await queryRunner.query(
  `CREATE TABLE "ext_my-extension_thing" (
    "happenedAt" ${resolveColumnType('datetime')} NOT NULL
  )`
);

The dialect is read from DB_TYPE at module load, exactly as the host reads it. Your extension runs in the Seerr process, so the two cannot disagree.

Publishing

An extension is installed by an operator from an npm package name or a git repository URL. In both cases what is installed is what you publish or commit: install runs no build step and no lifecycle scripts. Ship the compiled server entry point and any panel bundles.

For npm, make sure your built output is in files. For git, commit it.

Types

Seerr's own entity types (Media, MediaRequest, User) are re-declared here structurally as SeerrMedia, SeerrMediaRequest and SeerrUser, describing the persisted column surface. Seerr is not a published package, so there is nothing to depend on for the real classes; the host passes you real instances, which satisfy these interfaces. A conformance typecheck in the Seerr repository fails if the two ever disagree.

Core permission values are exported as SeerrPermission, an as const object rather than an enum, so its values are assignable to and from the host's Permission enum.

Trust model

Extensions are trusted code. They are require()d into the Seerr process and receive an SDK object; the manifest's requires shapes what that object contains, but it is capability hygiene, not a sandbox. Installing an extension is equivalent in risk to npm install of anything else. requires.http is documented and advisory in v1 — nothing enforces it.

License

MIT