Skip to content

Tutorial: your first catalog ​

We will build Tiny Catalog: a plugin that lists mods from a JSON file you host, so your Fabric servers get a Mods tab with your own curated list. Everything on this page is real: the files below are docs-site/examples/tiny-catalog, and the documentation build runs them against the real plugin sandbox, so they cannot drift from the API.

1. The catalog file ​

Host a JSON file over HTTPS (any static host works). It lists mods and, for each, a version, what it supports and the one file to install, including its SHA-512 and size:

json
{
  "items": [
    {
      "id": "hello-mod",
      "title": "Hello Mod",
      "summary": "Says hello.",
      "version": "1.2.0",
      "gameVersions": ["1.21.4"],
      "loaders": ["fabric"],
      "file": {
        "url": "https://downloads.example.org/hello-mod-1.2.0.jar",
        "sha512": "<128 hex characters>",
        "size": 12345
      }
    }
  ]
}

Compute the hash with sha512sum hello-mod-1.2.0.jar.

2. The manifest ​

json
{
  "id": "tiny-catalog",
  "name": "Tiny Catalog",
  "version": "1.0.0",
  "apiVersion": 1,
  "description": "A small catalog that lists mods from a JSON file you host yourself.",
  "author": "You",
  "license": "MIT",
  "permissions": ["network:downloads.example.org", "servers:read", "servers:files.write"],
  "catalogs": [{ "id": "tiny", "label": "Tiny catalog", "kind": "mod", "description": "Mods from our own file" }],
  "settings": [
    {
      "key": "indexUrl",
      "label": "Catalog file",
      "type": "string",
      "default": "https://downloads.example.org/catalog.json",
      "help": "HTTPS address of the JSON file that lists the mods."
    }
  ]
}
  • permissions declare everything the plugin may do. network:downloads.example.org allows HTTPS requests to that host and is the only place the panel lets a node download this plugin's files from. servers:read lets the panel pass the server's loaders and version to the catalog; servers:files.write lets the panel install files on a user's behalf.
  • catalogs says this plugin serves mods (kind: "mod"), which is what Fabric, Quilt, Forge and NeoForge servers ask for.
  • settings become a form in the install wizard; the value arrives as host.settings.indexUrl.

Full field list: Manifest and permissions.

3. The code ​

js
// A catalog plugin in about sixty lines. It reads one JSON file:
//
//   { "items": [ { "id": "hello-mod", "title": "Hello Mod", "summary": "Says hello.", "version": "1.2.0",
//                  "gameVersions": ["1.21.4"], "loaders": ["fabric"],
//                  "file": { "url": "https://downloads.example.org/hello-mod-1.2.0.jar",
//                            "sha512": "<128 hex characters>", "size": 12345 } } ] }

async function load() {
  const res = await host.fetch(host.settings.indexUrl);
  if (!res.ok) throw new Error('The catalog file answered ' + res.status);
  const body = await res.json();
  return Array.isArray(body.items) ? body.items : [];
}

// Does this entry fit the server? `target` describes it: { kind, loaders, gameVersion, type }.
function fits(item, target) {
  const loaderOk = !target.loaders || !target.loaders.length || item.loaders.some((l) => target.loaders.includes(l));
  const versionOk = !target.gameVersion || item.gameVersions.includes(target.gameVersion);
  return loaderOk && versionOk;
}

function summary(item) {
  return {
    id: item.id, slug: item.id, title: item.title, summary: item.summary || '', iconUrl: null, author: 'Tiny',
    downloads: 0, follows: 0, categories: [], clientSide: 'unknown', serverSide: 'required', updatedAt: null, url: null,
  };
}

function versionOf(item) {
  return {
    id: item.version, label: item.version, name: item.title + ' ' + item.version, channel: 'release', publishedAt: null,
    gameVersions: item.gameVersions, loaders: item.loaders, downloads: 0, changelog: '',
    files: [{ filename: item.file.url.split('/').pop(), size: item.file.size, primary: true }],
  };
}

globalThis.fledgePlugin = {
  async healthCheck() {
    const items = await load();
    return { ok: true, message: 'The catalog lists ' + items.length + ' mods.' };
  },

  async search(params, target) {
    const q = (params.query || '').toLowerCase();
    const all = (await load()).filter((i) => fits(i, target) && (i.title + ' ' + (i.summary || '')).toLowerCase().includes(q));
    const page = all.slice(params.offset, params.offset + params.limit).map(summary);
    return { total: all.length, offset: params.offset, limit: params.limit, items: page };
  },

  async categories() {
    return [];
  },

  async project(id) {
    const item = (await load()).find((i) => i.id === id);
    if (!item) throw new Error('No such mod: ' + id);
    return { ...summary(item), description: item.summary || '', license: null, publishedAt: null, gallery: [], links: {} };
  },

  async versions(id, target) {
    return (await load()).filter((i) => i.id === id && fits(i, target)).map(versionOf);
  },

  async resolve(args, target) {
    const item = (await load()).find((i) => i.id === args.projectId && fits(i, target));
    if (!item) throw new Error('This mod has no version for your server');
    return {
      project: { id: item.id, title: item.title, slug: item.id, iconUrl: null },
      version: { id: item.version, label: item.version, channel: 'release', publishedAt: null },
      files: [{ url: item.file.url, filename: item.file.url.split('/').pop(), sha512: item.file.sha512, size: item.file.size }],
      dependencies: [],
    };
  },

  async updates(args, target) {
    const items = await load();
    const out = [];
    for (const have of args.items) {
      const item = items.find((i) => i.id === have.projectId && fits(i, target));
      if (item && item.version !== have.versionId) out.push({ projectId: item.id, currentVersionId: have.versionId, latest: versionOf(item) });
    }
    return out;
  },
};

How it fits together:

  • globalThis.fledgePlugin is the one thing a plugin exports; every method may be async.
  • host.fetch is the only way out. It enforces your declared hosts, HTTPS and size limits; see the host API.
  • target is the server: { kind, loaders, gameVersion, type }. Return only what fits it.
  • resolve must return a SHA-512 and an exact size. The panel refuses anything else, and the node verifies the hash before writing the file. See the catalog contract for every method and the checks the panel applies.

4. Build the package ​

sh
node plugins/tools/pack.mjs pack docs-site/examples/tiny-catalog --out dist
# dist/tiny-catalog-1.0.0.fledgeplugin

5. Install and try it ​

  1. Plugins → Settings → Allow community plugins (your plugin is unsigned; see signing).
  2. Plugins → Install from file, choose the package, approve the permissions, set Catalog file to your URL and press Test connection, then turn it on.
  3. Open a Fabric server: the Mods tab now offers Tiny catalog next to any other catalog plugin.

6. Where to go next ​

  • Make search richer: pagination, categories, icons (icon hosts must be among your network: hosts).
  • Report dependencies in resolve: [{ projectId, type: 'required' }] makes the panel install them too.
  • Add hooks to react to events: Hooks.
  • Sign and publish through a registry: Packaging, signing, registry.
  • Test against the sandbox like the docs do: Testing.

Released under the AGPL-3.0-only license.