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.
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.
ctx.Route(name: string, path: string, HandlerClass: typeof Handler, ...permPrivChecker);
| Param | Type | Description |
|---|---|---|
name | string | Route name, used in url('route_name', { key: val }) to generate URLs |
path | string | URL path pattern, supports placeholders (e.g. ) |
:param/blog/:uid/:didHandlerClass | class extends Handler | The handler class |
...permPrivChecker | PERM / PRIV / Function / arrays | Permission checks applied at route level |
// Single permission (bitwise bigint)
ctx.Route('my_route', '/my', MyHandler, PERM.PERM_VIEW_PROBLEM);
// Single privilege (number)
ctx.Route('admin_route', '/admin', AdminHandler, PRIV.PRIV_EDIT_SYSTEM);
// Multiple: PERM array, PRIV array, or combined
ctx.Route('my_route', '/my', MyHandler, PERM.PERM_VIEW_PROBLEM, PRIV.PRIV_USER_PROFILE);
// Custom checker function
ctx.Route('my_route', '/my', MyHandler, (this: Handler) => {
if (!this.user.someCondition) throw new ForbiddenError();
});
The route name is derived from the Handler class name with Handler suffix removed. For example ContestDetailHandler → contest_detail. The name is used:
{{ url('contest_detail', { tid: tdoc._id }) }}this.url('contest_detail', { tid })data-page attribute on <html> for frontend page matchingWhen 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
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
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'; // Skip method execution, jump to 'after'
}
}
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.
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 };
}
}
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 });
}
}
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 {
// GET request
@param('did', Types.ObjectId)
async get(domainId: string, did: ObjectId) {
this.response.template = 'blog_detail.html';
this.response.body = { ddoc: await BlogModel.get(did) };
}
// POST without operation → post()
async post() {
this.checkPriv(PRIV.PRIV_USER_PROFILE);
}
// POST with operation=star → postStar()
@param('did', Types.ObjectId)
async postStar(domainId: string, did: ObjectId) {
await BlogModel.setStar(did, this.user._id, true);
this.back({ star: true });
}
// POST with operation=unstar → postUnstar()
@param('did', Types.ObjectId)
async postUnstar(domainId: string, did: ObjectId) {
await BlogModel.setStar(did, this.user._id, false);
this.back({ star: false });
}
}
all() methodRuns before the specific HTTP method. Useful for shared logic:
class MyHandler extends Handler {
async all() {
// Runs for GET, POST, PUT, DELETE, etc.
this.response.body = { sharedData: '...' };
}
async get() {
// GET-specific logic, can use this.response.body from all()
}
}
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 | — |
import { Types } from 'hydrooj';
// Also: any Schemastery Schema can be used as a type
Types.String // string, non-empty
Types.Int // integer
Types.PositiveInt // positive integer (> 0)
Types.Float // float number
Types.Boolean // boolean
Types.ObjectId // MongoDB ObjectId (24-char hex string)
Types.Title // string, length 1-256
Types.Content // string, non-empty
Types.Cuid // string, valid CUID
Types.Array // array
Types.UnsignedInt // integer >= 0
Types.Range // number range string like "1-100"
Types.PositiveFloat // float > 0
// Schema as type (validates AND converts)
@param('config', Schema.object({
name: Schema.string(),
value: Schema.number(),
}))
// Third argument = true → optional (undefined if missing)
@param('page', Types.PositiveInt, true)
async get(domainId: string, page = 1) { }
// Optional with convert: third arg = 'convert'
// Will pass through undefined but still apply convert if present
@param('filter', Types.String, 'convert')
// Fourth positional arg = validator function
@param('age', Types.Int, false, (v) => v >= 0 && v <= 150)
// Custom convert function
@param('ids', Types.String, false, null, (v) => v.split(',').map(Number))
// Full form: @param(name, type, isOptional, validator, converter)
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) {
// domainId is auto-injected, not from @param
// id comes from the decorated source
}
When the first argument name starts with domainId (case-insensitive), the framework detects this and passes domainId as the first argument automatically.
@requireSudoThe @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.
When applied to a handler method:
this.session.sudo exists and is less than 1 hour oldsession.sudoArgs, redirects to /user/sudo for password/2FA re-verificationimport { 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) {
// User must have re-authenticated within the last hour
await SystemModel.set(key, value);
this.back();
}
}
@requireSudoIMPORTANT: 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.
// After successful sudo, the session stores:
session.sudo = Date.now(); // Timestamp of sudo verification
session.sudoArgs = { // Original request state saved before redirect
method: 'post',
referer: request.headers.referer,
args: this.args,
redirect: request.originalPath,
};
// Sudo session expires after 1 hour (Time.hour)
// The stored referer is restored after sudo completes
this.response.template = 'my_page.html';
this.response.body = { title: 'Hello', items: [...] };
// Nunjucks renders my_page.html with the body as template context
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:
// Set in framework/framework/base.ts: