agentsclimarketplace

Hydro plugin hooks

Skill gtn1024/hydro-dev-skills/skills/hydro-plugin-hooks

Complete reference to Hydro's event system including event API (ctx.on/emit/broadcast), cluster-safe broadcasting, 60+ event types, resource cleanup (ctx.effect), timed tasks (ctx.interval), and replaceable modules (ctx.provideModule).From its SKILL.md

Install
npx -y skills add gtn1024/hydro-dev-skills --skill hydro-plugin-hooks

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

2 things to look at

  • no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
  • 3 stars3 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

18.0 KB, ~4.4k tokens by cl100k_base, as published. Nobody here has run it

Hydro Plugin Development: Event System & Hooks

This skill covers Hydro's event system: how to listen for system events, broadcast across processes, manage plugin lifecycle resources, and provide replaceable modules.


1. Event API Overview

All event methods are available on the Context object (ctx):

MethodSignatureDescription
ctx.on(event, handler)Returns dispose functionRegister listener
ctx.once(event, handler)Returns dispose functionRegister one-time listener
ctx.emit(event, ...args)voidSynchronous fire (in-process only)
ctx.parallel(event, ...args)Promise<void>Fire all listeners concurrently (in-process only)
ctx.serial(event, ...args)Promise<void>Fire listeners one by one, await each (in-process only)
ctx.broadcast(event, ...args)voidCross-process broadcast (see cluster safety below)

Cluster safety

ctx.emit() / ctx.parallel() / ctx.serial() — in-process only. They do NOT cross process boundaries. If you have PM2 with 4 instances, calling ctx.parallel('problem/add', ...) only triggers listeners in the current process.

ctx.broadcast() — cross-process AND cross-server (cluster safe). The implementation in packages/hydrooj/src/service/bus.ts and packages/hydrooj/src/model/task.ts:

  1. PM2 cluster mode (multiple processes on same server): Uses PM2 launchBus to relay events across all processes.
  2. Multi-server / standalone mode: Uses MongoDB event collection:
    • broadcast inserts a document into the event collection
    • Each server watches via MongoDB Change Stream (collEvent.watch())
    • If Change Stream unavailable (standalone MongoDB), falls back to polling (findOneAndUpdate every 500ms)
    • Events have ack array (server IDs that processed it) and expire (TTL auto-delete)
    • Each server processes unacknowledged events and calls app.parallel() locally
ctx.broadcast('record/judge', rdoc, updated, pdoc)
  → ctx.emit('bus/broadcast', 'record/judge', [rdoc, updated, pdoc])

  PM2 mode:
    → process.send({ type: 'hydro:broadcast', ... }) via PM2 bus
    → All processes on same server receive

  Multi-server / standalone:
    → Insert into MongoDB 'event' collection
    → Each server's watcher/poller detects new event
    → Each server calls app.parallel('record/judge', rdoc, updated, pdoc)

Rule of thumb for plugin developers:

  • If the event affects shared state (e.g., "record judged", "problem deleted"), use ctx.broadcast() so all processes and servers react.
  • If the event is local (e.g., handler lifecycle), ctx.parallel() is fine.
  • When listening: ctx.on() registers in the current process only. For broadcast events, every process/server has its own listeners, and broadcast triggers all of them.

2. Complete Event Catalog

Defined in packages/hydrooj/src/service/bus.ts (EventMap interface).

Application lifecycle

EventSignatureWhen
app/listen() => voidServer starts listening on port
app/started() => voidApplication startup complete
app/ready() => VoidReturnAll plugins loaded
app/exit() => VoidReturnApplication shutting down
app/before-reload(entries: Set<string>) => VoidReturnBefore hot-reloading plugins
app/reload(entries: Set<string>) => VoidReturnAfter hot-reloading plugins

Database

EventSignatureWhen
database/connect(db: Db) => voidMongoDB connection established
database/config() => VoidReturnDatabase config loaded

User

EventSignatureWhen
user/get(udoc: User) => voidUser data fetched
user/message(uid: number[], mdoc) => voidMessage sent to users
user/delcache(content: string | true) => voidUser cache invalidated
user/import/parse(payload: any) => VoidReturnParsing user import data
user/import/create(uid: number, udoc: any) => VoidReturnCreating imported user

Domain

EventSignatureWhen
domain/create(ddoc: DomainDoc) => VoidReturnDomain created
domain/before-get(query: Filter<DomainDoc>) => VoidReturnBefore fetching domain
domain/get(ddoc: DomainDoc) => VoidReturnDomain fetched
domain/before-update(domainId, $set) => VoidReturnBefore updating domain
domain/update(domainId, $set, ddoc) => VoidReturnDomain updated
domain/delete(domainId: string) => VoidReturnDomain deleted
domain/delete-cache(domainId: string) => VoidReturnDomain cache invalidated

Problem

EventSignatureWhen
problem/before-add(domainId, content, owner, docId, doc) => VoidReturnBefore creating problem
problem/add(doc: Partial<ProblemDoc>, docId: number) => VoidReturnProblem created
problem/before-edit(doc, $unset) => VoidReturnBefore editing problem
problem/edit(doc: ProblemDoc) => VoidReturnProblem edited
problem/before-del(domainId, docId) => VoidReturnBefore deleting problem
problem/del(domainId, docId) => VoidReturnProblem deleted
problem/list(query, handler, sort?) => VoidReturnProblem list queried
problem/get(doc: ProblemDoc, handler) => VoidReturnProblem fetched
problem/addTestdata(domainId, docId, name, payload) => VoidReturnTestdata file added
problem/renameTestdata(domainId, docId, name, newName) => VoidReturnTestdata renamed
problem/delTestdata(domainId, docId, name[]) => VoidReturnTestdata deleted
problem/addAdditionalFile(domainId, docId, name, payload) => VoidReturnAdditional file added

Contest

EventSignatureWhen
contest/before-add(payload) => VoidReturnBefore creating contest
contest/add(payload, id: ObjectId) => VoidReturnContest created
contest/edit(payload: Tdoc) => VoidReturnContest edited
contest/list(query, handler) => VoidReturnContest list queried
contest/scoreboard(tdoc, rows, udict, pdict) => VoidReturnScoreboard generated
contest/balloon(domainId, tid, bdoc) => VoidReturnBalloon event
contest/del(domainId, tid) => VoidReturnContest deleted

Record (submission)

EventSignatureWhen
record/change(rdoc, $set?, $push?, body?) => voidRecord updated
record/judge(rdoc, updated, pdoc?, updater?) => VoidReturnJudging complete

Discussion

EventSignatureWhen
discussion/before-add(payload) => VoidReturnBefore creating discussion
discussion/add(payload) => VoidReturnDiscussion created

Training

EventSignatureWhen
training/list(query, handler) => VoidReturnTraining list queried
training/get(tdoc, handler) => VoidReturnTraining fetched

Document

EventSignatureWhen
document/add(doc: any) => VoidReturnAny document added
document/set(domainId, docType, docId, $set, $unset) => VoidReturnDocument updated

System & monitoring

EventSignatureWhen
system/setting(args) => VoidReturnSystem setting changed
monitor/update(type, $set) => VoidReturnMonitor data updated
monitor/collect(info: any) => VoidReturnCollect monitoring info
api/update() => voidAPI registry updated
task/daily() => VoidReturnDaily task triggered
task/daily/finish(pref) => voidDaily tasks complete
oplog/log(type, handler, args, data) => VoidReturnOperation logged

Handler hooks

EventSignatureWhen
handler/create(h, type) => VoidReturnHandler instantiated
handler/init(h) => VoidReturnHandler init phase
handler/before-prepare(h) => VoidReturnBefore prepare phase
handler/before-prepare/${name}(h) => VoidReturnBefore prepare for specific handler
handler/before-prepare/${name}#${method}(h) => VoidReturnBefore prepare for specific handler+method
handler/before(h) => VoidReturnBefore main method
handler/before/${name}(h) => VoidReturnBefore specific handler
handler/after(h) => VoidReturnAfter main method
handler/after/${name}(h) => VoidReturnAfter specific handler
handler/after/${name}#${method}(h) => VoidReturnAfter specific handler+method
handler/finish(h) => VoidReturnHandler finished
handler/error(h, e) => VoidReturnHandler error
handler/error/${name}(h, e) => VoidReturnHandler error for specific handler

WebSocket / Subscription

EventSignatureWhen
subscription/init(h, privileged) => VoidReturnWebSocket connection init
subscription/subscribe(channel, user, metadata) => VoidReturnClient subscribes
subscription/enable(channel, h, privileged, onDispose) => VoidReturnSubscription activated

File watching (dev mode)

EventSignatureWhen
app/watch/change(path: string) => VoidReturnFile changed
app/watch/unlink(path: string) => VoidReturnFile deleted

3. Real-world Examples

Search index sync (ElasticSearch plugin)

this.ctx.on('problem/add', async (doc, docId) => {
    await this.client.index({
        index: 'problem',
        id: `${doc.domainId}/${docId}`,
        document: processDocument(doc),
    });
});

this.ctx.on('problem/edit', async (pdoc) => {
    await this.client.index({
        index: 'problem',
        id: `${pdoc.domainId}/${pdoc.docId}`,
        document: processDocument(pdoc),
    });
});

this.ctx.on('problem/del', async (domainId, docId) => {
    await this.client.delete({
        index: 'problem',
        id: `${domainId}/${docId}`,
    });
});

Post-handler hook (UI Default plugin)

// Modify response after a specific handler method runs
ctx.on('handler/after/DiscussionRaw', async (that) => {
    if (that.args.render && that.response.type === 'text/markdown') {
        that.response.type = 'text/html';
        that.response.body = await markdown.render(that.response.body);
    }
});

// Run after ALL handlers
ctx.on('handler/after', async (that) => {
    that.UiContext.SWConfig = {
        preload: SystemModel.get('ui-default.preload'),
        // ...
    };
});

One-time setup on user registration (A11Y plugin)

ctx.on('handler/after/UserRegisterWithCode#post', async (that) => {
    if (that.session.uid === 2) await UserModel.setSuperAdmin(2);
});

Modify domain query before execution

ctx.on('domain/before-get', (query) => {
    // Modify the query before it's executed
    query.someField = 'value';
});

Monitor contest balloon events

ctx.on('contest/balloon', (domainId, tid, bdoc) => {
    // Send notification, update scoreboard, etc.
});

4. ctx.effect() — Automatic Resource Cleanup

ctx.effect() registers a cleanup function that runs when the plugin is unloaded or the context is disposed.

// Inside a Service class
constructor(ctx: Context) {
    super(ctx, 'myService');

    ctx.effect(() => {
        const conn = createConnection();
        const timer = setInterval(() => { /* ... */ }, 5000);
        return () => {
            conn.close();
            clearInterval(timer);
        };
    });
}

Real example (VJudge plugin)

this.ctx.effect(() => {
    this.providers[type] = provider;
    const services = [];
    for (const account of this.accounts.filter((a) => a.type === type)) {
        if (account.enableOn && !account.enableOn.includes(os.hostname())) continue;
        const service = new AccountService(provider, account, this.ctx);
        services.push(service);
        this.pool[`${account.type}/${account.handle}`] = service;
    }
    return () => {
        // Cleanup: stop all services for this provider
        for (const service of services) service.stop();
        delete this.providers[type];
    };
});

ctx.on() already returns a dispose function

const dispose = ctx.on('problem/add', handler);
// dispose is called automatically when plugin unloads
// You can also call dispose() manually to unregister early

5. ctx.interval() — Scheduled Tasks

Registers a recurring task that auto-cancels on plugin unload.

ctx.interval(async () => {
    // This runs every 5 seconds (adjustable via config)
    const metrics = await collectMetrics();
    ctx.broadcast('metrics', hostname(), metrics);
}, 5000);

Real example (Prometheus client)

ctx.interval(async () => {
    try {
        const [gateway, name, pass] = SystemModel.getMany([
            'prom-client.gateway', 'prom-client.name', 'prom-client.password',
        ]);
        if (gateway) {
            const prefix = gateway.endsWith('/') ? gateway : `${gateway}/`;
            const endpoint = `${prefix}metrics/job/hydro-web/instance/${encodeURIComponent(hostname())}:${process.env.NODE_APP_INSTANCE}`;
            let req = superagent.post(endpoint);
            if (name) req = req.auth(name, pass, { type: 'basic' });
            await req.send(await registry.metrics());
        } else {
            ctx.broadcast('metrics', `${hostname()}/${process.env.NODE_APP_INSTANCE}`, await registry.getMetricsAsJSON());
        }
    } catch (e) {
        pushError = e.message;
    }
}, 5000 * (+SystemModel.get('prom-client.collect_rate') || 1));

Real example (VJudge — weekly sync)

this.ctx.interval(this.sync.bind(this), Time.week);

6. ctx.provideModule() — Replaceable Modules

Registers a named implementation for a module type. Multiple modules can coexist; the system or user selects which one to use.

Currently supported module types

TypeInterfacePurpose
hash(password: string, salt: string, user: User) => boolean | string | Promise<string>Password hashing algorithm
problemSearch(domainId: string, q: string, opts?) => Promise<ProblemSearchResponse>Problem search backend
richmedia{ get(service, src, md) => string }Rich media rendering

Registration

// Register a search backend
ctx.provideModule('problemSearch', 'elastic', async (domainId, q, opts) => {
    const limit = opts?.limit || 20;
    const result = await client.search({
        index: 'problem',
        body: { query: { multi_match: { query: q, fields: ['title', 'content'] } } },
        size: limit,
    });
    return {
        hits: result.hits.hits.map((h) => h._id),
        total: result.hits.total.value,
        countRelation: result.hits.total.relation,
    };
});

Cleanup (auto via ctx.effect)

// provideModule returns a dispose function
const dispose = ctx.provideModule('problemSearch', 'mybackend', mySearchFn);
// Auto-cleaned on plugin unload, or call dispose() manually

7. Generator-based event registration (using yield)

In Service constructors, you can use yield this.ctx.on(...) for cleaner lifecycle management:

// Inside a service that uses generators
* [Context.init]() {
    yield this.ctx.on('problem/add', async (doc, docId) => { /* ... */ });
    yield this.ctx.on('problem/edit', async (pdoc) => { /* ... */ });
    yield this.ctx.provideModule('problemSearch', 'mysearch', this.search.bind(this));
}

The yield pattern ensures the returned dispose function is tracked by the Cordis framework and called automatically on disposal.


8. Event Flow Diagram

                          ┌─────────────┐
                          │   Plugin A  │
                          │  ctx.on()   │
                          └──────┬──────┘
                                 │
┌──────────────┐    emit     ┌───┴───────────────────┐
│  Core System │───────────► │  Cordis Event System  │
│  (models,    │             │                       │
│   handlers)  │    ◄─────── │  ctx.parallel()       │
└──────┬───────┘  broadcast  │  ctx.serial()         │
       │                      └───┬───────────────────┘
       │                          │
       │              ┌───────────┴───────────┐
       │              │                       │
       │        ┌─────┴─────┐          ┌──────┴──────┐
       │        │ Plugin B  │          │ Plugin C    │
       │        │ listener  │          │ listener    │
       │        └───────────┘          └─────────────┘
       │
       │   ctx.broadcast() for cross-process
       │          │
       │    ┌─────┴─────────────────────┐
       │    │  PM2 Bus / MongoDB Events │
       │    │  (cross-process relay)    │
       │    └───────────────────────────┘

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 326,852. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.