| name | hydro-plugin-overview |
| description | Hydro plugin fundamentals including entry patterns (function-style apply, class-style Service, and mixed mode), import conventions, plugin package structure, database access (ctx.db and deprecated db), management scripts (ctx.addScript), and a complete minimal working example. |
Hydro Plugin Development: Overview & Fundamentals
This skill covers the absolute basics of writing a Hydro plugin: the entry function, import conventions, plugin package structure, and a minimal working example.
1. Plugin Entry
Every Hydro plugin must export one of:
Function-style (most common)
import { Context, Handler, param, Types, PRIV } from 'hydrooj';
class MyHandler extends Handler {
@param('id', Types.String, true)
async get(domainId: string, id?: string) {
this.response.body = { message: 'Hello', id };
}
}
export async function apply(ctx: Context) {
ctx.Route('my_route', '/my/:id', MyHandler);
ctx.on('problem/add', (doc, docId) => {
console.log('Problem added:', docId);
});
}
The plugin loader detects a named apply export directly and uses it as the entry point.
Class-style (stateful plugins with config/dependencies)
import { Context, Service } from 'hydrooj';
import Schema from 'schemastery';
export default class MyService extends Service {
static inject = ['db'];
static Config = Schema.object({
apiKey: Schema.string().required(),
timeout: Schema.number().default(30),
});
constructor(ctx: Context, config: ReturnType<typeof MyService.Config>) {
super(ctx, 'myService');
this.ctx.on('problem/add', (doc, docId) => { });
}
}
Why this works without apply: The plugin loader detects class constructors automatically. Cordis then instantiates the class via ctx.plugin(MyService, config), calling the constructor with (ctx, config).
Important: This automatic config injection only applies when the service class itself is the plugin entry. If you also export a named apply, then apply becomes the plugin entry instead of the default class.
Limitation: Class-style alone cannot register routes, settings, or UI injections outside the constructor. For those, use the mixed pattern below.
Mixed style (class + apply, e.g. geoip package)
Export both a class (as default) for service capabilities and a named apply for registration:
import { Context, Service, Handler } from 'hydrooj';
export default class GeoIPService extends Service {
constructor(ctx: Context) {
super(ctx, 'geoip');
}
lookup(ip: string, locale: string) { }
}
export function apply(ctx: Context) {
ctx.plugin(GeoIPService);
ctx.Route('geoip', '/api/geoip/:ip', GeoIPHandler);
}
The loader prioritizes named apply as the entry point. Inside apply, you must call ctx.plugin(GeoIPService) to register the service instance. After that, other code can access it via ctx.geoip or dependency injection (static inject = ['geoip']).
If the mixed-style plugin needs config, export Config at the module level and accept config in apply. If apply then registers a service class, it must forward that config explicitly:
import { Context, Schema, Service } from 'hydrooj';
export const Config = Schema.object({
apiKey: Schema.string().required(),
});
export default class MyService extends Service {
static Config = Config;
constructor(ctx: Context, config: ReturnType<typeof Config>) {
super(ctx, 'myService');
}
}
export function apply(ctx: Context, config: ReturnType<typeof Config>) {
ctx.plugin(MyService, config);
ctx.Route('my_route', '/my', MyHandler);
}
If you write export function apply(ctx) and then call ctx.plugin(MyService) without passing config, the loader-resolved config will stop at apply and never reach MyService.
Note: Class-style plugins do not need to define an apply method โ the loader detects classes automatically. Function-style plugins must export a named apply. In the mixed style, apply takes priority as the entry point, so module-level Config and apply(ctx, config) are the safe pattern when configuration is involved.
2. Import Conventions
ALWAYS import from 'hydrooj' โ it is the unified public API surface. All public APIs are re-exported from the hydrooj package.
When developing a plugin as an external npm package, you install hydrooj as a dependency and import everything from it. You do NOT need access to the Hydro source repo โ the hydrooj npm package contains all type definitions.
import {
Context, Service, Handler, ConnectionHandler,
param, get, post, route, query, subscribe,
Types, PRIV, PERM, STATUS,
ObjectId, db,
EventMap,
UserFacingError,
DocumentModel, ProblemModel, RecordModel,
} from 'hydrooj';
import Handler from 'hydrooj/src/service/server';
import { ObjectId } from 'hydrooj/src/model/basic';
Tip for AI assistants: If you're unsure what's available from hydrooj, check node_modules/hydrooj/dist/plugin-api.js (or .d.ts) for the full list of exports. You can also find the API index at https://hydro-plugin-api-reference.pages.dev/llms.txt
The key exported categories are:
- Core:
Context, Service, Handler, ConnectionHandler
- Decorators:
param, get, post, route, query, subscribe
- Validation:
Types
- Permissions:
PERM, PRIV, STATUS
- Database:
db, ObjectId, Collections
- Models:
*Model (UserModel, ProblemModel, RecordModel, DomainModel, ContestModel, DocumentModel, DiscussionModel, StorageModel, SettingModel, etc.)
- Errors:
UserFacingError and various specific error classes
- Utilities:
_ (lodash), nanoid, moment
- Event types:
EventMap
3. Plugin Package Structure
A plugin is an npm package.
Minimal structure
my-plugin/
โโโ package.json # Must have "name" and "main" fields
โโโ index.ts # Default export: Service class OR named export: apply()
Full structure (with templates and i18n)
my-plugin/
โโโ package.json
โโโ index.ts # Backend entry
โโโ templates/ # Nunjucks HTML templates (auto-discovered by TemplateService)
โ โโโ my_page.html
โ โโโ my_detail.html
โโโ locales/ # Optional: separate i18n files
package.json requirements
{
"name": "hydro-plugin-my-feature",
"main": "index.ts",
"version": "1.0.0",
"dependencies": {
"hydrooj": "*"
}
}
The plugin name is used for:
- Version tracking (
global.Hydro.version[name])
- Module path resolution (
global.addons[name])
- Config scoping in the database
4. Complete Minimal Example: Blog Plugin
This is a real plugin from packages/blog/index.ts, simplified:
import {
_, Context, DiscussionNotFoundError, DocumentModel,
Handler, ObjectId, OplogModel, param, PRIV, Types, UserModel,
} from 'hydrooj';
export const TYPE_BLOG = 70 as const;
export interface BlogDoc {
docType: 70;
docId: ObjectId;
owner: number;
title: string;
content: string;
views: number;
}
declare module 'hydrooj' {
interface Model { blog: typeof BlogModel; }
interface DocType { [TYPE_BLOG]: BlogDoc; }
}
export class BlogModel {
static async add(owner: number, title: string, content: string, ip?: string) {
const res = await DocumentModel.add(
'system', content, owner, TYPE_BLOG, null, null, null,
_.omit({ title, ip, nReply: 0, views: 0, updateAt: new Date() }, ['domainId']),
);
return res;
}
static async get(did: ObjectId) {
return await DocumentModel.get('system', TYPE_BLOG, did);
}
static edit(did: ObjectId, title: string, content: string) {
return DocumentModel.set('system', TYPE_BLOG, did, { title, content });
}
static del(did: ObjectId) {
return DocumentModel.deleteOne('system', TYPE_BLOG, did);
}
static getMulti(query = {}) {
return DocumentModel.getMulti('system', TYPE_BLOG, query).sort({ _id: -1 });
}
}
global.Hydro.model.blog = BlogModel;
class BlogUserHandler extends Handler {
@param('uid', Types.Int)
@param('page', Types.PositiveInt, true)
async get(domainId: string, uid: number, page = 1) {
const [ddocs, dpcount] = await this.ctx.db.paginate(
BlogModel.getMulti({ owner: uid }), page, 10,
);
const udoc = await UserModel.getById(domainId, uid);
this.response.template = 'blog_main.html';
this.response.body = { ddocs, dpcount, udoc, page };
}
}
class BlogEditHandler extends Handler {
@param('title', Types.Title)
@param('content', Types.Content)
async postCreate(domainId: string, title: string, content: string) {
await this.limitRate('add_blog', 3600, 60);
const did = await BlogModel.add(this.user._id, title, content, this.request.ip);
this.response.redirect = this.url('blog_detail', { uid: this.user._id, did });
}
}
export async function apply(ctx: Context) {
ctx.Route('blog_main', '/blog/:uid', BlogUserHandler);
ctx.Route('blog_create', '/blog/:uid/create', BlogEditHandler, PRIV.PRIV_USER_PROFILE);
ctx.injectUI('UserDropdown', 'blog_main',
(h) => ({ icon: 'book', displayName: 'Blog', uid: h.user._id.toString() }),
PRIV.PRIV_USER_PROFILE,
);
ctx.i18n.load('zh', {
"{0}'s blog": '{0} ็ๅๅฎข',
Blog: 'ๅๅฎข',
});
ctx.i18n.load('en', {
blog_main: 'Blog',
});
}
And the corresponding template templates/blog_main.html:
{% extends "layout/basic.html" %}
{% block content %}
<div class="row">
<div class="medium-9 columns">
<div class="section">
<ol class="section__list">
{%- for ddoc in ddocs -%}
<li class="section__list__item">
<h1><a href="{{ url('blog_detail', uid=udoc._id, did=ddoc._id) }}">{{ ddoc.title }}</a></h1>
</li>
{%- endfor -%}
</ol>
</div>
</div>
</div>
{% endblock %}
5. Database Access (ctx.db / db)
The db service provides MongoDB access. There are two ways to use it:
Via ctx.db (recommended)
Available as this.ctx.db in handlers or ctx.db in services:
ctx.db.collection<K extends keyof Collections>(name: K): Collection<Collections[K]>
const [docs, numPages, count] = await ctx.db.paginate(cursor, page, pageSize);
const [ranked, docs] = await ctx.db.ranked(cursor, (a, b) => a.score === b.score);
await ctx.db.ensureIndexes(collection, ...indexDescriptions);
await ctx.db.clearIndexes(collection, dropIndexNames?);
Via import { db } from 'hydrooj' (deprecated but widely used)
import { db } from 'hydrooj';
const coll = db.collection('user');
const docs = await coll.find({}).toArray();
Note: This db export is marked @deprecated โ it accesses the MongoService via a global proxy (app.get('db')). Despite being deprecated, it is still widely used throughout the Hydro ecosystem and all existing plugins. Prefer ctx.db in new code, but you will encounter db frequently in existing codebases.
Accessing raw MongoDB
ctx.db.client
ctx.db.db
db.client
db.db
Common patterns
const [items, pageCount, count] = await this.ctx.db.paginate(
MyModel.getMulti(domainId, { status: 'active' }),
page,
20,
);
import { db } from 'hydrooj';
const coll = db.collection('my_collection');
await coll.updateOne({ _id: oid }, { $set: { field: value } });
6. Management Scripts (ctx.addScript)
Scripts appear in the System Control Panel โ Scripts page. They are admin-only tools for batch operations, data migration, or maintenance tasks.
Registration
ctx.addScript(
name: string,
description: string,
validate: Schema<T>,
run: (args: T, report: (msg: string) => void) => boolean | Promise<boolean>,
);
The run callback receives:
args โ validated input matching the schema
report โ progress callback, shows messages in the UI during execution
Return true to indicate success, or throw an error for failure.
Example: batch cleanup script
import { Context, Schema, iterateAllProblem } from 'hydrooj';
export async function apply(ctx: Context) {
ctx.addScript('clean_orphan_data', 'Remove orphan data from problems',
Schema.object({
dryRun: Schema.boolean().default(true).description('Only report, do not delete'),
}),
async (args, report) => {
let count = 0;
await iterateAllProblem(['domainId', 'docId', 'title'], async (pdoc, i, total) => {
if (i % 100 === 0) report(`Processing ${i}/${total}...`);
const hasOrphan = checkOrphan(pdoc);
if (hasOrphan) {
count++;
if (!args.dryRun) await cleanupOrphan(pdoc);
}
});
report(`Found ${count} problems with orphan data`);
return true;
},
);
}
Built-in iteration helpers (from hydrooj)
import { iterateAllDomain, iterateAllUser, iterateAllContest,
iterateAllProblem, iterateAllRecord, iterateAllPsdoc } from 'hydrooj';
await iterateAllDomain(async (ddoc, current?, total?) => { ... });
await iterateAllUser(async (udoc, current?, total?) => { ... });
await iterateAllProblem(['title', 'content'], async (pdoc, current?, total?) => {
return { title: pdoc.title.trim() };
});
7. Key Concepts Summary
| Concept | API | Purpose |
|---|
| Entry | export function apply(ctx, config?) or export default class extends Service or both | Plugin bootstrap |
| Routes | ctx.Route(name, path, Handler, ...perms) | HTTP endpoints |
| Events | ctx.on(event, handler) | React to system events |
| UI Injection | ctx.injectUI(position, name, args, ...perms) | Add UI elements |
| Modules | ctx.provideModule(type, id, impl) | Register replaceable modules |
| Scripts | ctx.addScript(name, desc, schema, run) | Admin batch operations |
| Config | static Config = Schema.object({...}) | Plugin settings |
| i18n | ctx.i18n.load(lang, dict) | Translations |
| DB Access | ctx.db.collection(), ctx.db.paginate() | MongoDB operations |
| Templates | templates/*.html directory | Nunjucks server-rendered HTML |
See also: hydro-plugin-handler for routes/handlers, hydro-plugin-hooks for events, hydro-plugin-frontend for templates/UI/i18n, hydro-plugin-services for storage/settings/oauth.