| name | hydro-plugin-handler |
| description | Comprehensive guide to HTTP request handling and WebSocket connections in Hydro plugins. Covers route registration, handler lifecycle, parameter decorators, response building, operations, the @requireSudo security decorator, WebSocket handlers, and inheritance patterns. |
Hydro Plugin Development: Handler & Route System
This skill covers how to handle HTTP requests and WebSocket connections in Hydro plugins: Handler lifecycle, route registration, parameter decorators, response building, the operation mechanism, and WebSocket handlers.
1. Route Registration
ctx.Route(name: string, path: string, HandlerClass: typeof Handler, ...permPrivChecker);
Parameters
| Param | Type | Description |
|---|
name | string | Route name, used in url('route_name', { key: val }) to generate URLs |
path | string | URL path pattern, supports :param placeholders (e.g. /blog/:uid/:did) |
HandlerClass | class extends Handler | The handler class |
...permPrivChecker | PERM / PRIV / Function / arrays | Permission checks applied at route level |
Permission checker variants
ctx.Route('my_route', '/my', MyHandler, PERM.PERM_VIEW_PROBLEM);
ctx.Route('admin_route', '/admin', AdminHandler, PRIV.PRIV_EDIT_SYSTEM);
ctx.Route('my_route', '/my', MyHandler, PERM.PERM_VIEW_PROBLEM, PRIV.PRIV_USER_PROFILE);
ctx.Route('my_route', '/my', MyHandler, (this: Handler) => {
if (!this.user.someCondition) throw new ForbiddenError();
});
Route name conventions
The route name is derived from the Handler class name with Handler suffix removed. For example ContestDetailHandler → contest_detail. The name is used:
- In templates:
{{ url('contest_detail', { tid: tdoc._id }) }}
- In handlers:
this.url('contest_detail', { tid })
- In
data-page attribute on <html> for frontend page matching
2. Handler Lifecycle (Complete Steps)
When an HTTP request arrives, WebService.handleHttp() executes the following steps in order. Each step is either a handler method call, an event hook, or a log marker.
Step Type Description
──────────────────────────────────────────────────────────────────
log/__init log Record init timestamp
init method CSRF protection, basic setup
handler/init event ctx.serial('handler/init', h)
handler/before-prepare/* event ctx.serial('handler/before-prepare/${name}#${method}', h)
event ctx.serial('handler/before-prepare/${name}', h)
event ctx.serial('handler/before-prepare', h)
log/__prepare log Record prepare start
__prepare method Base class data loading (lowest layer)
_prepare method Intermediate data loading
prepare method Final pre-check / business setup
log/__prepareDone log Record prepare end
handler/before/* event ctx.serial('handler/before/${name}#${method}', h)
event ctx.serial('handler/before/${name}', h)
event ctx.serial('handler/before', h)
log/__method log Record method start
all method Runs for ALL HTTP methods
method method 'get', 'post', 'put', 'delete', etc.
log/__methodDone log Record method end
[post${operation}] method Only for POST with body.operation
log/__operationDone log Record operation end
after method Post-method cleanup / UI setup
handler/after/* event ctx.serial('handler/after/${name}#${method}', h)
event ctx.serial('handler/after/${name}', h)
event ctx.serial('handler/after', h)
cleanup method Final cleanup (always runs)
handler/finish/* event ctx.serial('handler/finish/${name}#${method}', h)
event ctx.serial('handler/finish/${name}', h)
event ctx.serial('handler/finish', h)
log/__finish log Record finish timestamp
Error handling (any step throws)
handler/error/${name} event ctx.serial('handler/error/${name}', h, e)
handler/error event ctx.serial('handler/error', h, e)
onerror method h.onerror(e) → renders error page
Control flow
Any handler method can return a step name to jump to that step:
async prepare() {
if (!this.user.hasPriv(PRIV.PRIV_USER_PROFILE)) {
this.response.redirect = '/login';
return 'after';
}
}
The three prepare layers
Hydro uses an inheritance-based convention for the prepare chain:
| Method | Layer | Purpose | Example |
|---|
__prepare | Base class | Load core shared data | ContestDetailBaseHandler.__prepare loads tdoc/tsdoc |
_prepare | Intermediate | Load related data, preprocess params | ProblemHandler._prepare loads pdoc by pid |
prepare | Final subclass | Permission checks, business-specific setup | ContestDetailHandler.prepare checks if contest is hidden |
This allows subclass handlers to inherit data loading from parent classes without repeating code.
3. HTTP Method Handling
GET handler
class MyHandler extends Handler {
@param('id', Types.ObjectId)
@param('page', Types.PositiveInt, true)
async get(domainId: string, id: ObjectId, page = 1) {
const data = await MyModel.get(domainId, id);
this.response.template = 'my_detail.html';
this.response.body = { data, page };
}
}
POST handler (simple)
class MyHandler extends Handler {
@param('title', Types.Title)
@param('content', Types.Content)
async post(domainId: string, title: string, content: string) {
const id = await MyModel.create(domainId, title, content);
this.response.redirect = this.url('my_detail', { id });
}
}
POST with operation mechanism
When POST body contains an { "operation": "star" } field, the framework converts it to a method name:
operation: "star" → calls postStar()
operation: "delete_item" → calls postDeleteItem() (snake_case → camelCase)
class BlogDetailHandler extends Handler {
@param('did', Types.ObjectId)
async get(domainId: string, did: ObjectId) {
this.response.template = 'blog_detail.html';
this.response.body = { ddoc: await BlogModel.get(did) };
}
async post() {
this.checkPriv(PRIV.PRIV_USER_PROFILE);
}
@param('did', Types.ObjectId)
async postStar(domainId: string, did: ObjectId) {
await BlogModel.setStar(did, this.user._id, true);
this.back({ star: true });
}
@param('did', Types.ObjectId)
async postUnstar(domainId: string, did: ObjectId) {
await BlogModel.setStar(did, this.user._id, false);
this.back({ star: false });
}
}
The all() method
Runs before the specific HTTP method. Useful for shared logic:
class MyHandler extends Handler {
async all() {
this.response.body = { sharedData: '...' };
}
async get() {
}
}
4. Parameter Decorators
Decorators extract and validate parameters from different parts of the request.
| Decorator | Source | Example |
|---|
@param(name, Type, ...) | GET query + POST body + route params | General purpose |
@get(name, Type, ...) | URL query string only (?key=val) | GET parameters |
@post(name, Type, ...) | Request body only | POST form/JSON |
@route(name, Type, ...) | URL path parameters (/:id) | Path params |
@query(name, Type, ...) | Alias for @get | — |
Type specifications
import { Types } from 'hydrooj';
Types.String
Types.Int
Types.PositiveInt
Types.Float
Types.Boolean
Types.ObjectId
Types.Title
Types.Content
Types.Cuid
Types.Array
Types.UnsignedInt
Types.Range
Types.PositiveFloat
@param('config', Schema.object({
name: Schema.string(),
value: Schema.number(),
}))
Optional parameters
@param('page', Types.PositiveInt, true)
async get(domainId: string, page = 1) { }
@param('filter', Types.String, 'convert')
Validation + Convert
@param('age', Types.Int, false, (v) => v >= 0 && v <= 150)
@param('ids', Types.String, false, null, (v) => v.split(',').map(Number))
The domainId convention
domainId is always the first parameter and is automatically injected from the request context. It does NOT come from the decorator source — it's extracted from the route or the session.
@param('id', Types.ObjectId)
async get(domainId: string, id: ObjectId) {
}
When the first argument name starts with domainId (case-insensitive), the framework detects this and passes domainId as the first argument automatically.
5. Security Decorator: @requireSudo
The @requireSudo decorator protects sensitive operations by requiring the user to re-authenticate (similar to Linux sudo). This prevents accidental destructive actions when a superadmin is logged in with "remember password" enabled.
How it works
When applied to a handler method:
- Checks if
this.session.sudo exists and is less than 1 hour old
- If valid sudo session → proceeds normally
- If no sudo session → saves current request state in
session.sudoArgs, redirects to /user/sudo for password/2FA re-verification
- After verification, the user is redirected back and the original operation resumes
Usage
import { requireSudo, Handler, param, Types, ObjectId } from 'hydrooj';
class AdminSettingsHandler extends Handler {
@param('key', Types.String)
@param('value', Types.String)
@requireSudo
async postUpdate(domainId: string, key: string, value: string) {
await SystemModel.set(key, value);
this.back();
}
}
When to use @requireSudo
- Superadmin account settings (password change, 2FA setup)
- Destructive operations that should not be triggered by remembered sessions
- Operations where a shared computer / classroom scenario could be exploited
IMPORTANT: Teachers using superadmin accounts in classrooms is a key security concern. Always use @requireSudo for operations that could cause serious damage if an unauthorized person triggers them from a remembered session.
Session behavior
session.sudo = Date.now();
session.sudoArgs = {
method: 'post',
referer: request.headers.referer,
args: this.args,
redirect: request.originalPath,
};
6. Response Building
Render a template (HTML page)
this.response.template = 'my_page.html';
this.response.body = { title: 'Hello', items: [...] };
IMPORTANT — request.json behavior: If the request has Accept: application/json header, the template is NOT rendered — this.response.body is returned as JSON instead. This is controlled by request.json:
json: (ctx.request.headers.accept || '').includes('application/json'),
When request.json is true:
- Template rendering is skipped entirely
response.body is serialized to JSON (response.type = 'application/json')
response.redirect is also skipped (redirect URL is included in JSON body as url field)
- Error responses are JSON too:
{ error: ... }
This means the same handler/route serves both HTML and JSON based on the Accept header:
if (this.request.json) {
return;
}
async get(domainId: string, tid: ObjectId) {
this.response.template = 'contest_detail.html';
this.response.body = { tdoc, tsdoc };
if (this.request.json) return;
this.response.body.tdoc.content = ;
}
Return JSON (API endpoint)
this.response.body = { success: true, data: [...] };
Redirect
this.response.redirect = this.url('my_detail', { id });
this.response.redirect = '/some/path';
Back to referer
this.back({ star: true });
File download
this.binary(buffer, 'report.pdf');
Set response status
this.response.status = 201;
this.response.type = 'text/plain';
Set response headers
this.response.set('X-Custom-Header', 'value');
Available response properties
| Property | Type | Description |
|---|
this.response.body | any | Response data (object for JSON, template context for HTML) |
this.response.template | string | Nunjucks template name |
this.response.redirect | string | Redirect URL |
this.response.status | number | HTTP status code |
this.response.type | string | Content-Type |
7. Handler Context Properties
Every handler has access to these built-in properties:
| Property | Type | Description |
|---|
this.user | User | Current logged-in user (or guest) |
this.domain | DomainDoc | Current domain |
this.request | KoaRequest | Koa request object |
this.response | KoaResponse | Koa response object |
this.args | Record<string, any> | All parsed arguments + timing data |
this.session | Session | Session data |
this.ctx | Context | Cordis context (access services) |
Common handler methods
this.checkPerm(PERM.PERM_EDIT_PROBLEM);
this.checkPriv(PRIV.PRIV_EDIT_SYSTEM);
this.checkPriv(PRIV.PRIV_USER_PROFILE);
this.limitRate('add_blog', 3600, 60);
this.url('route_name', { key: val });
this.translate('key');
8. The after() Method
The after() method runs after the main HTTP method handler and before the handler/after event hooks. It's commonly used for post-processing the response.
Real example from ContestDetailBaseHandler:
@param('tid', Types.ObjectId, true)
async after(domainId: string, tid: ObjectId) {
if (!tid || this.tdoc.rule === 'homework') return;
if (this.request.json || !this.response.template) return;
this.response.body.overrideNav = [
{ name: 'contest_main', args: {}, displayName: 'Back to contest list', checker: () => true },
{ name: 'contest_detail', displayName: this.tdoc.title, args: { tid }, checker: () => true },
{ name: 'contest_scoreboard', args: { tid, prefix: 'contest_scoreboard' },
checker: () => contest.canShowScoreboard.call(this, this.tdoc, true) },
];
}
9. WebSocket (ConnectionHandler)
WebSocket connections use ConnectionHandler instead of Handler.
Basic WebSocket handler
import { ConnectionHandler, subscribe } from 'hydrooj';
class MyWsHandler extends ConnectionHandler<Context> {
@subscribe('my-channel')
async send(channel: string) {
}
async prepare() {
}
async message(payload: any) {
}
async cleanup() {
}
}
Registering a WebSocket route
ctx.Connection('my_ws', '/ws/my-channel', MyWsHandler);
Using WebSocket events
ctx.on('subscription/enable', (channel, h, privileged, onDispose) => {
onDispose(() => { });
});
WebSocket lifecycle
handler/create events fire
- Permission checker runs
_prepare() → prepare() called
- Subscriptions registered via
@subscribe decorator
- Connection enters active state
message() handler receives incoming messages
cleanup() runs on disconnect/error
SSE (Server-Sent Events) mode
ConnectionHandler also supports SSE when accessed via HTTP (not WebSocket upgrade):
10. Handler Inheritance Patterns
Handlers support class inheritance for sharing logic:
class ContestDetailBaseHandler extends Handler {
tdoc?: Tdoc;
tsdoc?: any;
@param('tid', Types.ObjectId, true)
async __prepare(domainId: string, tid: ObjectId) {
if (!tid) return;
[this.tdoc, this.tsdoc] = await Promise.all([
contest.get(domainId, tid),
contest.getStatus(domainId, tid, this.user._id),
]);
}
}
class ContestScoreboardHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectId)
async prepare(domainId: string, tid: ObjectId) {
if (!contest.canShowScoreboard.call(this, this.tdoc, true)) {
throw new ContestNotLiveError(tid);
}
}
@param('tid', Types.ObjectId)
async get(domainId: string, tid: ObjectId) {
this.response.template = 'contest_scoreboard.html';
this.response.body = { tdoc: this.tdoc };
}
}
Inheritance + prepare chain
When using inheritance, the three prepare layers map naturally:
__prepare in the base class — load shared data (e.g., tdoc)
_prepare in intermediate class — load secondary data (e.g., pdoc)
prepare in the leaf class — permission checks, final setup
Each layer uses @param decorators independently. The framework collects all @param decorators across the inheritance chain for each method name.
11. Common Patterns
CRUD Handler (combines GET + multiple POST operations)
class ItemHandler extends Handler {
item?: ItemDoc;
@param('id', Types.ObjectId, true)
async _prepare(domainId: string, id?: ObjectId) {
if (id) {
this.item = await ItemModel.get(domainId, id);
if (!this.item) throw new NotFoundError(id);
}
}
@param('page', Types.PositiveInt, true)
async get(domainId: string, page = 1) {
const [items, count] = await this.ctx.db.paginate(
ItemModel.getMulti(domainId), page, 20,
);
this.response.template = 'item_list.html';
this.response.body = { items, count, page };
}
@param('title', Types.Title)
@param('content', Types.Content)
async post(domainId: string, title: string, content: string) {
this.checkPerm(PERM.PERM_CREATE_PROBLEM);
await this.limitRate('create_item', 60, 10);
const id = await ItemModel.add(domainId, title, content, this.user._id);
this.response.redirect = this.url('item_detail', { id });
}
@param('id', Types.ObjectId)
@param('title', Types.Title)
async postEdit(domainId: string, id: ObjectId, title: string) {
if (!this.user.own(this.item!)) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
await ItemModel.edit(domainId, id, { title });
this.back();
}
@param('id', Types.ObjectId)
async postDelete(domainId: string, id: ObjectId) {
if (!this.user.own(this.item!)) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
await ItemModel.del(domainId, id);
this.response.redirect = this.url('item_list');
}
}
ctx.Route('item_list', '/item', ItemHandler);
ctx.Route('item_detail', '/item/:id', ItemHandler);
JSON API Handler
class ApiHandler extends Handler {
@param('query', Types.String)
async get(domainId: string, query: string) {
const results = await searchItems(domainId, query);
this.response.body = { results };
}
}