hydro-plugin-hooks — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited hydro-plugin-hooks (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
This skill covers Hydro's event system: how to listen for system events, broadcast across processes, manage plugin lifecycle resources, and provide replaceable modules.
All event methods are available on the Context object (ctx):
| Method | Signature | Description |
|---|---|---|
ctx.on(event, handler) | Returns dispose function | Register listener |
ctx.once(event, handler) | Returns dispose function | Register one-time listener |
ctx.emit(event, ...args) | void | Synchronous 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) | void | Cross-process broadcast (see cluster safety below) |
`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:
launchBus to relay events across all processes.event collection:broadcast inserts a document into the event collectioncollEvent.watch())findOneAndUpdate every 500ms)ack array (server IDs that processed it) and expire (TTL auto-delete)app.parallel() locallyctx.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:
ctx.broadcast() so all processes and servers react.ctx.parallel() is fine.ctx.on() registers in the current process only. For broadcast events, every process/server has its own listeners, and broadcast triggers all of them.Defined in packages/hydrooj/src/service/bus.ts (EventMap interface).
| Event | Signature | When |
|---|---|---|
app/listen | () => void | Server starts listening on port |
app/started | () => void | Application startup complete |
app/ready | () => VoidReturn | All plugins loaded |
app/exit | () => VoidReturn | Application shutting down |
app/before-reload | (entries: Set<string>) => VoidReturn | Before hot-reloading plugins |
app/reload | (entries: Set<string>) => VoidReturn | After hot-reloading plugins |
| Event | Signature | When |
|---|---|---|
database/connect | (db: Db) => void | MongoDB connection established |
database/config | () => VoidReturn | Database config loaded |
| Event | Signature | When | |
|---|---|---|---|
user/get | (udoc: User) => void | User data fetched | |
user/message | (uid: number[], mdoc) => void | Message sent to users | |
user/delcache | `(content: string \ | true) => void` | User cache invalidated |
user/import/parse | (payload: any) => VoidReturn | Parsing user import data | |
user/import/create | (uid: number, udoc: any) => VoidReturn | Creating imported user |
| Event | Signature | When |
|---|---|---|
domain/create | (ddoc: DomainDoc) => VoidReturn | Domain created |
domain/before-get | (query: Filter<DomainDoc>) => VoidReturn | Before fetching domain |
domain/get | (ddoc: DomainDoc) => VoidReturn | Domain fetched |
domain/before-update | (domainId, $set) => VoidReturn | Before updating domain |
domain/update | (domainId, $set, ddoc) => VoidReturn | Domain updated |
domain/delete | (domainId: string) => VoidReturn | Domain deleted |
domain/delete-cache | (domainId: string) => VoidReturn | Domain cache invalidated |
| Event | Signature | When |
|---|---|---|
problem/before-add | (domainId, content, owner, docId, doc) => VoidReturn | Before creating problem |
problem/add | (doc: Partial<ProblemDoc>, docId: number) => VoidReturn | Problem created |
problem/before-edit | (doc, $unset) => VoidReturn | Before editing problem |
problem/edit | (doc: ProblemDoc) => VoidReturn | Problem edited |
problem/before-del | (domainId, docId) => VoidReturn | Before deleting problem |
problem/del | (domainId, docId) => VoidReturn | Problem deleted |
problem/list | (query, handler, sort?) => VoidReturn | Problem list queried |
problem/get | (doc: ProblemDoc, handler) => VoidReturn | Problem fetched |
problem/addTestdata | (domainId, docId, name, payload) => VoidReturn | Testdata file added |
problem/renameTestdata | (domainId, docId, name, newName) => VoidReturn | Testdata renamed |
problem/delTestdata | (domainId, docId, name[]) => VoidReturn | Testdata deleted |
problem/addAdditionalFile | (domainId, docId, name, payload) => VoidReturn | Additional file added |
| Event | Signature | When |
|---|---|---|
contest/before-add | (payload) => VoidReturn | Before creating contest |
contest/add | (payload, id: ObjectId) => VoidReturn | Contest created |
contest/edit | (payload: Tdoc) => VoidReturn | Contest edited |
contest/list | (query, handler) => VoidReturn | Contest list queried |
contest/scoreboard | (tdoc, rows, udict, pdict) => VoidReturn | Scoreboard generated |
contest/balloon | (domainId, tid, bdoc) => VoidReturn | Balloon event |
contest/del | (domainId, tid) => VoidReturn | Contest deleted |
| Event | Signature | When |
|---|---|---|
record/change | (rdoc, $set?, $push?, body?) => void | Record updated |
record/judge | (rdoc, updated, pdoc?, updater?) => VoidReturn | Judging complete |
| Event | Signature | When |
|---|---|---|
discussion/before-add | (payload) => VoidReturn | Before creating discussion |
discussion/add | (payload) => VoidReturn | Discussion created |
| Event | Signature | When |
|---|---|---|
training/list | (query, handler) => VoidReturn | Training list queried |
training/get | (tdoc, handler) => VoidReturn | Training fetched |
| Event | Signature | When |
|---|---|---|
document/add | (doc: any) => VoidReturn | Any document added |
document/set | (domainId, docType, docId, $set, $unset) => VoidReturn | Document updated |
| Event | Signature | When |
|---|---|---|
system/setting | (args) => VoidReturn | System setting changed |
monitor/update | (type, $set) => VoidReturn | Monitor data updated |
monitor/collect | (info: any) => VoidReturn | Collect monitoring info |
api/update | () => void | API registry updated |
task/daily | () => VoidReturn | Daily task triggered |
task/daily/finish | (pref) => void | Daily tasks complete |
oplog/log | (type, handler, args, data) => VoidReturn | Operation logged |
| Event | Signature | When |
|---|---|---|
handler/create | (h, type) => VoidReturn | Handler instantiated |
handler/init | (h) => VoidReturn | Handler init phase |
handler/before-prepare | (h) => VoidReturn | Before prepare phase |
handler/before-prepare/${name} | (h) => VoidReturn | Before prepare for specific handler |
handler/before-prepare/${name}#${method} | (h) => VoidReturn | Before prepare for specific handler+method |
handler/before | (h) => VoidReturn | Before main method |
handler/before/${name} | (h) => VoidReturn | Before specific handler |
handler/after | (h) => VoidReturn | After main method |
handler/after/${name} | (h) => VoidReturn | After specific handler |
handler/after/${name}#${method} | (h) => VoidReturn | After specific handler+method |
handler/finish | (h) => VoidReturn | Handler finished |
handler/error | (h, e) => VoidReturn | Handler error |
handler/error/${name} | (h, e) => VoidReturn | Handler error for specific handler |
| Event | Signature | When |
|---|---|---|
subscription/init | (h, privileged) => VoidReturn | WebSocket connection init |
subscription/subscribe | (channel, user, metadata) => VoidReturn | Client subscribes |
subscription/enable | (channel, h, privileged, onDispose) => VoidReturn | Subscription activated |
| Event | Signature | When |
|---|---|---|
app/watch/change | (path: string) => VoidReturn | File changed |
app/watch/unlink | (path: string) => VoidReturn | File deleted |
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}`,
});
});// 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'),
// ...
};
});ctx.on('handler/after/UserRegisterWithCode#post', async (that) => {
if (that.session.uid === 2) await UserModel.setSuperAdmin(2);
});ctx.on('domain/before-get', (query) => {
// Modify the query before it's executed
query.someField = 'value';
});ctx.on('contest/balloon', (domainId, tid, bdoc) => {
// Send notification, update scoreboard, etc.
});ctx.effect() — Automatic Resource Cleanupctx.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);
};
});
}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 functionconst dispose = ctx.on('problem/add', handler);
// dispose is called automatically when plugin unloads
// You can also call dispose() manually to unregister earlyctx.interval() — Scheduled TasksRegisters 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);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));this.ctx.interval(this.sync.bind(this), Time.week);ctx.provideModule() — Replaceable ModulesRegisters a named implementation for a module type. Multiple modules can coexist; the system or user selects which one to use.
| Type | Interface | Purpose | ||
|---|---|---|---|---|
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 |
// 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,
};
});// provideModule returns a dispose function
const dispose = ctx.provideModule('problemSearch', 'mybackend', mySearchFn);
// Auto-cleaned on plugin unload, or call dispose() manuallyyield)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.
┌─────────────┐
│ 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) │
│ └───────────────────────────┘~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.