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."
}
]
}permissionsdeclare everything the plugin may do.network:downloads.example.orgallows HTTPS requests to that host and is the only place the panel lets a node download this plugin's files from.servers:readlets the panel pass the server's loaders and version to the catalog;servers:files.writelets the panel install files on a user's behalf.catalogssays this plugin serves mods (kind: "mod"), which is what Fabric, Quilt, Forge and NeoForge servers ask for.settingsbecome a form in the install wizard; the value arrives ashost.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.fledgePluginis the one thing a plugin exports; every method may beasync.host.fetchis the only way out. It enforces your declared hosts, HTTPS and size limits; see the host API.targetis the server:{ kind, loaders, gameVersion, type }. Return only what fits it.resolvemust 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.fledgeplugin5. Install and try it
- Plugins → Settings → Allow community plugins (your plugin is unsigned; see signing).
- Plugins → Install from file, choose the package, approve the permissions, set Catalog file to your URL and press Test connection, then turn it on.
- 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
hooksto react to events: Hooks. - Sign and publish through a registry: Packaging, signing, registry.
- Test against the sandbox like the docs do: Testing.
