diff --git a/README.md b/README.md index 96da7a6f0..58427098b 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Telegram [@undefinedmoe](https://t.me/undefinedmoe)

更新日志(点击展开)

-### Hydro 4.10.7 / 4.49.5 +### Hydro 4.10.7 / UI 4.49.5 - core: 优化比赛成绩版按照小组筛选 - core: inject -> injectUI - core: 修复一处内存泄漏 diff --git a/package.json b/package.json index 6d41c9ec1..b8beac3db 100644 --- a/package.json +++ b/package.json @@ -34,18 +34,17 @@ "devDependencies": { "@simplewebauthn/typescript-types": "7.4.0", "@types/autocannon": "^7.12.5", - "@types/chai": "^4.3.11", + "@types/chai": "^4.3.12", "@types/cross-spawn": "^6.0.6", "@types/node": "20.10.6", - "@types/semver": "^7.5.7", + "@types/semver": "^7.5.8", "autocannon": "^7.15.0", "cac": "^6.7.14", "chai": "^5.1.0", - "chokidar": "^3.6.0", "cross-env": "^7.0.3", "cross-spawn": "^7.0.3", "esbuild": "0.18.20", - "eslint": "^8.56.0", + "eslint": "^8.57.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-webpack": "^0.13.8", "fs-extra": "^11.2.0", @@ -54,7 +53,7 @@ "mongodb-memory-server": "^9.1.6", "nyc": "^15.1.0", "ora": "^8.0.1", - "package-json": "^9.0.0", + "package-json": "^10.0.0", "semver": "^7.6.0", "simple-git": "^3.22.0", "supertest": "^6.3.4", diff --git a/packages/blog/index.ts b/packages/blog/index.ts index c9765b2ac..20c04a5b3 100644 --- a/packages/blog/index.ts +++ b/packages/blog/index.ts @@ -212,7 +212,7 @@ export async function apply(ctx: Context) { ctx.Route('blog_create', '/blog/:uid/create', BlogEditHandler, PRIV.PRIV_USER_PROFILE); ctx.Route('blog_detail', '/blog/:uid/:did', BlogDetailHandler); ctx.Route('blog_edit', '/blog/:uid/:did/edit', BlogEditHandler, PRIV.PRIV_USER_PROFILE); - ctx.inject('UserDropdown', 'blog_main', (h) => ({ icon: 'book', displayName: 'Blog', uid: h.user._id.toString() }), + 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} 的博客', diff --git a/packages/elastic/package.json b/packages/elastic/package.json index f1a666bc9..0fe89483e 100644 --- a/packages/elastic/package.json +++ b/packages/elastic/package.json @@ -6,6 +6,6 @@ "author": "undefined ", "license": "AGPL-3.0-or-later", "dependencies": { - "@elastic/elasticsearch": "^8.12.1" + "@elastic/elasticsearch": "^8.12.2" } } diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index be306bf3e..647835be5 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -4,15 +4,15 @@ "license": "MIT", "main": "react.yaml", "dependencies": { - "@stylistic/eslint-plugin": "^1.6.2", - "@typescript-eslint/eslint-plugin": "^7.0.2", - "@typescript-eslint/parser": "^7.0.2", + "@stylistic/eslint-plugin": "^1.6.3", + "@typescript-eslint/eslint-plugin": "^7.1.1", + "@typescript-eslint/parser": "^7.1.1", "eslint-config-airbnb": "^19.0.4", - "eslint-config-airbnb-typescript": "^17.1.0", - "eslint-plugin-github": "^4.10.1", + "eslint-config-airbnb-typescript": "^18.0.0", + "eslint-plugin-github": "^4.10.2", "eslint-plugin-import": "2.29.0", "eslint-plugin-jsx-a11y": "^6.8.0", - "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react": "^7.34.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-simple-import-sort": "^12.0.0" }, diff --git a/packages/fps-importer/index.ts b/packages/fps-importer/index.ts index 4cbd9deab..fbde0523e 100644 --- a/packages/fps-importer/index.ts +++ b/packages/fps-importer/index.ts @@ -142,7 +142,7 @@ class FpsProblemImportHandler extends Handler { export async function apply(ctx: Context) { ctx.Route('problem_import_fps', '/problem/import/fps', FpsProblemImportHandler, PERM.PERM_CREATE_PROBLEM); - ctx.inject('ProblemAdd', 'problem_import_fps', { icon: 'copy', text: 'From FPS File' }); + ctx.injectUI('ProblemAdd', 'problem_import_fps', { icon: 'copy', text: 'From FPS File' }); ctx.i18n.load('zh', { 'From FPS File': '从 FPS 文件导入', }); diff --git a/packages/geoip/index.ts b/packages/geoip/index.ts index d581650db..f3a8b6678 100644 --- a/packages/geoip/index.ts +++ b/packages/geoip/index.ts @@ -13,7 +13,11 @@ export interface Result { display: string } -class GeoIPService extends Service { +export default class GeoIPService extends Service { + constructor(ctx: Context) { + super(ctx, 'geoip', true); + } + provider = 'MaxMind'; lookup(ip: string, locale: string): Result { const res: any = reader.get(ip); @@ -30,7 +34,8 @@ class GeoIPService extends Service { return ret; } } -export function apply(ctx: Context) { - Context.service('geoip', GeoIPService); - ctx.geoip = new GeoIPService(ctx, 'geoip', true); + +export async function apply(ctx: Context) { + ctx.provide('geoip'); + ctx.geoip = new GeoIPService(ctx); } diff --git a/packages/hydrojudge/src/hosts/builtin.ts b/packages/hydrojudge/src/hosts/builtin.ts index a94074672..230b197f7 100644 --- a/packages/hydrojudge/src/hosts/builtin.ts +++ b/packages/hydrojudge/src/hosts/builtin.ts @@ -79,7 +79,9 @@ const session = { export async function postInit(ctx) { if (SystemModel.get('hydrojudge.disable')) return; - ctx.check.addChecker('Judge', (_ctx, log, warn, error) => versionCheck(warn, error)); + ctx.inject(['check'], (c) => { + c.check.addChecker('Judge', (_ctx, log, warn, error) => versionCheck(warn, error)); + }); await fs.ensureDir(getConfig('tmp_dir')); const handle = async (t) => { const rdoc = await RecordModel.get(t.domainId, t.rid); diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index b482f014c..155ab7d19 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -12,23 +12,23 @@ }, "preferUnplugged": true, "dependencies": { - "@aws-sdk/client-s3": "3.515.0", - "@aws-sdk/lib-storage": "3.515.0", - "@aws-sdk/s3-presigned-post": "3.515.0", - "@aws-sdk/s3-request-presigner": "3.515.0", - "@graphql-tools/schema": "^10.0.2", + "@aws-sdk/client-s3": "3.525.0", + "@aws-sdk/lib-storage": "3.525.1", + "@aws-sdk/s3-presigned-post": "3.525.0", + "@aws-sdk/s3-request-presigner": "3.525.0", + "@graphql-tools/schema": "^10.0.3", "@hydrooj/utils": "workspace:*", "@simplewebauthn/server": "7.4.0", "@undefined-moe/require-resolve-hook": "^1.1.0", "adm-zip": "0.5.5", "cac": "^6.7.14", - "cordis": "2.8.8", + "cordis": "3.13.3", "detect-browser": "^5.3.0", "emoji-regex": "^10.3.0", "emojis-list": "2.1.0", "fs-extra": "^11.2.0", "graphql": "^16.8.1", - "graphql-scalars": "^1.22.4", + "graphql-scalars": "^1.22.5", "js-yaml": "^4.1.0", "koa": "^2.15.0", "koa-body": "^6.0.1", @@ -42,7 +42,7 @@ "moment-timezone": "^0.5.45", "mongodb": "^5.9.2", "nanoid": "^5.0.6", - "nodemailer": "^6.9.10", + "nodemailer": "^6.9.11", "notp": "^2.0.3", "p-queue": "^8.0.1", "path-to-regexp": "^6.2.1", @@ -69,9 +69,10 @@ "@types/nodemailer": "^6.4.14", "@types/notp": "^2.0.5", "@types/saslprep": "^1.0.3", - "@types/semver": "^7.5.7", - "@types/superagent": "^8.1.3", + "@types/semver": "^7.5.8", + "@types/superagent": "^8.1.4", "@types/tar": "^6.1.11", + "chokidar": "^3.6.0", "moment": "^2.30.1" } } diff --git a/packages/hydrooj/src/context.ts b/packages/hydrooj/src/context.ts index 0dc36d7e3..f2f308fbe 100644 --- a/packages/hydrooj/src/context.ts +++ b/packages/hydrooj/src/context.ts @@ -6,8 +6,7 @@ import { Loader } from './loader'; import { EventMap } from './service/bus'; import type { CheckService } from './service/check'; -export interface Events extends cordis.Events, EventMap { -} +export interface Events extends cordis.Events, EventMap { } function addScript(name: string, description: string, validate: Schema, run: (args: K, report: any) => boolean | Promise) { if (global.Hydro.script[name]) throw new Error(`duplicate script ${name} registered.`); @@ -21,6 +20,12 @@ function provideModule(type: T, id: string, mo return () => delete global.Hydro.module[type][id]; } +export type EffectScope = cordis.EffectScope; +export type ForkScope = cordis.ForkScope; +export type MainScope = cordis.MainScope; + +export type { Disposable, ScopeStatus, Plugin } from 'cordis'; + export interface Context { [Context.events]: Events; loader: Loader; @@ -28,55 +33,62 @@ export interface Context { Connection: typeof import('./service/server').Connection; withHandlerClass: import('./service/server').RouteService['withHandlerClass']; check: CheckService; - setTimeout: typeof setTimeout; - setInterval: typeof setInterval; setImmediate: typeof setImmediate; addScript: typeof addScript; provideModule: typeof provideModule; - /** @deprecated use injectUI instead */ - inject: typeof inject; injectUI: typeof inject; - api: ApiMixin; broadcast: Context['emit']; geoip?: GeoIP; } -export class Context extends cordis.Context { - domain?: DomainDoc; -} - -export namespace Context { - export interface Config extends cordis.Context.Config { } -} - -export type MainScope = cordis.MainScope; -export type EffectScope = cordis.EffectScope; -export type ForkScope = cordis.ForkScope; -export type Plugin = cordis.Plugin; -export const Service = cordis.Service; -export namespace Plugin { - export type Function = cordis.Plugin.Function; - export type Constructor = cordis.Plugin.Constructor; - export type Object = cordis.Plugin.Object; +export abstract class Service extends cordis.Service { + [cordis.Service.setup]() { + this.ctx = new Context() as C; + } } const T = any>(origFunc: F, disposeFunc?) => function method(this: cordis.Service, ...args: Parameters) { const res = origFunc(...args); - this.caller?.on('dispose', () => (disposeFunc ? disposeFunc(res) : res())); + this[Context.current]?.on('dispose', () => (disposeFunc ? disposeFunc(res) : res())); }; + export class ApiMixin extends Service { - static readonly methods = ['addScript', 'setInterval', 'setTimeout', 'setImmediate', 'provideModule', 'inject', 'injectUI', 'broadcast']; addScript = T(addScript); - setInterval = T(setInterval, clearInterval); - setTimeout = T(setTimeout, clearTimeout); setImmediate = T(setImmediate, clearImmediate); provideModule = T(provideModule); - inject = T(inject); injectUI = T(inject); broadcast = (event: keyof EventMap, ...payload) => this.ctx.emit('bus/broadcast', event, payload); constructor(ctx) { - super(ctx, 'api', true); + super(ctx, '$api', true); + ctx.mixin('$api', ['addScript', 'setImmediate', 'provideModule', 'injectUI', 'broadcast']); } } -Context.service('api', ApiMixin); + +export class Context extends cordis.Context { + domain?: DomainDoc; + + constructor(config: {} = {}) { + super(config); + this.plugin(ApiMixin); + } + + /** @deprecated use `ctx.root` instead */ + get app() { + return this.root; + } +} + +export namespace Context { + export type Associate

= cordis.Context.Associate; +} + +const old = cordis.Registry.prototype.inject; +// cordis.Registry.prototype.using = old; +cordis.Registry.prototype.inject = function wrapper(...args) { + if (typeof args[0] === 'string') { + console.warn('old functionality of ctx.inject is deprecated. please use ctx.injectUI instead.'); + return T(inject).call(this, ...args as any) as any; + } + return old.call(this, ...args); +}; diff --git a/packages/hydrooj/src/entry/cli.ts b/packages/hydrooj/src/entry/cli.ts index da145cc12..b89481b8b 100644 --- a/packages/hydrooj/src/entry/cli.ts +++ b/packages/hydrooj/src/entry/cli.ts @@ -107,6 +107,7 @@ export async function load(ctx: Context) { lib(pending, fail, ctx), service(pending, fail, ctx), ]); + ctx.plugin(require('../service/worker')); await builtinModel(ctx); await model(pending, fail, ctx); await setting(pending, fail, require('../model/setting')); diff --git a/packages/hydrooj/src/entry/worker.ts b/packages/hydrooj/src/entry/worker.ts index 4bab46a3f..f44cef15f 100644 --- a/packages/hydrooj/src/entry/worker.ts +++ b/packages/hydrooj/src/entry/worker.ts @@ -38,6 +38,7 @@ export async function apply(ctx: Context) { await modelSystem.runConfig(); const storage = require('../service/storage'); await storage.loadStorageService(); + await require('../service/worker').apply(ctx); await require('../service/server').apply(ctx); // Make sure everything is ready and then start main entry if (argv.options.watch) ctx.plugin(require('../service/watcher').default, {}); diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 53ba6cff8..9ffe4efa9 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -57,20 +57,6 @@ registerResolver( 'Get a contest by ID', ); -ScheduleModel.Worker.addHandler('contest', async (doc) => { - const tdoc = await contest.get(doc.domainId, doc.tid); - if (!tdoc) return; - const tasks = []; - for (const op of doc.operation) { - if (op === 'unhide') { - for (const pid of tdoc.pids) { - tasks.push(problem.edit(doc.domainId, pid, { hidden: false })); - } - } - } - await Promise.all(tasks); -}); - export class ContestListHandler extends Handler { @param('rule', Types.Range(contest.RULES), true) @param('group', Types.Name, true) @@ -776,4 +762,17 @@ export async function apply(ctx: Context) { ctx.Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_user', '/contest/:tid/user', ContestUserHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_balloon', '/contest/:tid/balloon', ContestBalloonHandler, PERM.PERM_VIEW_CONTEST); + ctx.worker.addHandler('contest', async (doc) => { + const tdoc = await contest.get(doc.domainId, doc.tid); + if (!tdoc) return; + const tasks = []; + for (const op of doc.operation) { + if (op === 'unhide') { + for (const pid of tdoc.pids) { + tasks.push(problem.edit(doc.domainId, pid, { hidden: false })); + } + } + } + await Promise.all(tasks); + }); } diff --git a/packages/hydrooj/src/handler/judge.ts b/packages/hydrooj/src/handler/judge.ts index 9f82a99a7..fc93790bf 100644 --- a/packages/hydrooj/src/handler/judge.ts +++ b/packages/hydrooj/src/handler/judge.ts @@ -300,6 +300,10 @@ export class JudgeConnectionHandler extends ConnectionHandler { this.query.lang = { $in: msg.lang }; this.consumer?.setQuery(this.query); } + if (msg.type instanceof Array && msg.type.every((i) => typeof i === 'string')) { + this.query.type = { $in: msg.type }; + this.consumer?.setQuery(this.query); + } } else if (msg.key === 'start') { clearTimeout(this.startTimeout); this.consumer ||= task.consume(this.query, this.newTask.bind(this), true, this.concurrency); diff --git a/packages/hydrooj/src/lib/i18n.ts b/packages/hydrooj/src/lib/i18n.ts index 04836d30b..a59b23a0c 100644 --- a/packages/hydrooj/src/lib/i18n.ts +++ b/packages/hydrooj/src/lib/i18n.ts @@ -21,7 +21,7 @@ class I18nService extends Service { load(lang: string, content: Record) { translations[lang] ||= []; translations[lang].unshift(content); - this.caller?.on('dispose', () => { + this[Context.current]?.on('dispose', () => { translations[lang] = translations[lang].filter((i) => i !== content); }); } @@ -35,7 +35,7 @@ class I18nService extends Service { } } -Context.service('i18n'); +app.provide('i18n', undefined, true); app.i18n = new I18nService(app); String.prototype.translate = function translate(...languages: string[]) { diff --git a/packages/hydrooj/src/loader.ts b/packages/hydrooj/src/loader.ts index bfacacbe0..d08c75768 100644 --- a/packages/hydrooj/src/loader.ts +++ b/packages/hydrooj/src/loader.ts @@ -34,7 +34,6 @@ export function resolveConfig(plugin: any, config: any) { if (schema && plugin['schema'] !== false) config = schema(config); return config; } -Context.service('loader'); const timeout = Symbol.for('loader.timeout'); const showLoadTime = argv.options.showLoadTime; @@ -43,7 +42,7 @@ export class Loader { static readonly Record = Symbol.for('loader.record'); public app: Context; - public config: Context.Config; + public config: {}; public suspend = false; public cache: Record = Object.create(null); @@ -147,8 +146,8 @@ export function addScript(name: string, description: string) { }; } -Context.service('loader'); const loader = new Loader(); +app.provide('loader'); app.loader = loader; loader.app = app; app.state[Loader.Record] = Object.create(null); diff --git a/packages/hydrooj/src/model/schedule.ts b/packages/hydrooj/src/model/schedule.ts index 92e3f5878..4c78f7ea6 100644 --- a/packages/hydrooj/src/model/schedule.ts +++ b/packages/hydrooj/src/model/schedule.ts @@ -1,11 +1,10 @@ import moment from 'moment-timezone'; import { Filter, ObjectId } from 'mongodb'; -import { sleep } from '@hydrooj/utils/lib/utils'; -import { Context, Service } from '../context'; +import { Context } from '../context'; import { Schedule } from '../interface'; import { Logger } from '../logger'; -import * as bus from '../service/bus'; import db from '../service/db'; +import type { WorkerService } from '../service/worker'; import RecordModel from './record'; const logger = new Logger('model/schedule'); @@ -27,73 +26,6 @@ async function getFirst(query: Filter) { return null; } -class Consumer { - consuming: boolean; - running?: any; - - constructor(public filter: any, public func: Function, public destroyOnError = true) { - this.consuming = true; - this.consume(); - bus.on('app/exit', this.destroy); - } - - async consume() { - while (this.consuming) { - try { - // eslint-disable-next-line no-await-in-loop - const res = await getFirst(this.filter); - if (res) { - this.running = res; - // eslint-disable-next-line no-await-in-loop - await this.func(res); - this.running = null; - // eslint-disable-next-line no-await-in-loop - } else await sleep(1000); - } catch (err) { - logger.error(err); - if (this.destroyOnError) this.destroy(); - } - } - } - - async destroy() { - this.consuming = false; - } -} - -class WorkerService extends Service { - private handlers: Record = {}; - public consumer = new Consumer( - { type: 'schedule', subType: { $in: Object.keys(this.handlers) } }, - async (doc) => { - try { - logger.debug('Worker task: %o', doc); - const start = Date.now(); - await Promise.race([ - this.handlers[doc.subType](doc), - sleep(1200000), - ]); - const spent = Date.now() - start; - if (spent > 500) logger.warn('Slow worker task (%d ms): %o', spent, doc); - } catch (e) { - logger.error('Worker task fail: ', e); - logger.error('%o', doc); - } - }, - false, - ); - - public addHandler(type: string, handler: Function) { - this.handlers[type] = handler; - this.consumer.filter = { type: 'schedule', subType: { $in: Object.keys(this.handlers) } }; - this.caller?.on('dispose', () => { - delete this.handlers[type]; - }); - } -} - -const Worker = new WorkerService(app, 'worker', false); - class ScheduleModel { static coll = coll; @@ -123,28 +55,24 @@ class ScheduleModel { } static getFirst = getFirst; - static Worker = Worker; -} - -declare module '../context' { - interface Context { - worker: WorkerService; - } + /** @deprecated use ctx.inject(['worker'], cb) instead */ + static Worker: WorkerService; } export async function apply(ctx: Context) { - Context.service('worker', WorkerService); - ctx.worker = Worker; - - Worker.addHandler('task.daily', async () => { - await RecordModel.coll.deleteMany({ contest: { $in: [RecordModel.RECORD_PRETEST, RecordModel.RECORD_GENERATE] } }); - await global.Hydro.script.rp?.run({}, new Logger('task/rp').debug); - await global.Hydro.script.problemStat?.run({}, new Logger('task/problem').debug); - if (global.Hydro.model.system.get('server.checkUpdate') && !(new Date().getDay() % 3)) { - await global.Hydro.script.checkUpdate?.run({}, new Logger('task/checkUpdate').debug); - } - await ctx.parallel('task/daily'); + ctx.inject(['worker'], (c) => { + ScheduleModel.Worker = c.worker; + c.worker.addHandler('task.daily', async () => { + await RecordModel.coll.deleteMany({ contest: { $in: [RecordModel.RECORD_PRETEST, RecordModel.RECORD_GENERATE] } }); + await global.Hydro.script.rp?.run({}, new Logger('task/rp').debug); + await global.Hydro.script.problemStat?.run({}, new Logger('task/problem').debug); + if (global.Hydro.model.system.get('server.checkUpdate') && !(new Date().getDay() % 3)) { + await global.Hydro.script.checkUpdate?.run({}, new Logger('task/checkUpdate').debug); + } + await ctx.parallel('task/daily'); + }); }); + ctx.on('domain/delete', (domainId) => coll.deleteMany({ domainId })); if (process.env.NODE_APP_INSTANCE !== '0') return; diff --git a/packages/hydrooj/src/model/setting.ts b/packages/hydrooj/src/model/setting.ts index f93a6d3da..3ae7ff7b6 100644 --- a/packages/hydrooj/src/model/setting.ts +++ b/packages/hydrooj/src/model/setting.ts @@ -1,6 +1,6 @@ /* eslint-disable max-len */ /* eslint-disable no-await-in-loop */ -import cordis from 'cordis'; +import * as cordis from 'cordis'; import yaml from 'js-yaml'; import { Dictionary } from 'lodash'; import moment from 'moment-timezone'; @@ -300,11 +300,10 @@ declare module '../context' { const T = any>(origFunc: F, disposeFunc?) => function method(this: cordis.Service, ...args: Parameters) { const res = origFunc(...args); - this.caller?.on('dispose', () => (disposeFunc ? disposeFunc(res) : res())); + this[Context.current]?.on('dispose', () => (disposeFunc ? disposeFunc(res) : res())); }; export class SettingService extends Service { - static readonly methods = ['PreferenceSetting', 'AccountSetting', 'DomainSetting', 'DomainUserSetting', 'SystemSetting']; PreferenceSetting = T(PreferenceSetting); AccountSetting = T(AccountSetting); DomainSetting = T(DomainSetting); @@ -312,16 +311,16 @@ export class SettingService extends Service { SystemSetting = T(SystemSetting); constructor(ctx: Context) { super(ctx, 'setting', true); + ctx.mixin('setting', ['PreferenceSetting', 'AccountSetting', 'DomainSetting', 'DomainUserSetting', 'SystemSetting']); } get(key: string) { - return (this.caller ? this.caller.domain?.config?.[key.replace(/\./g, '$')] : null) ?? global.Hydro.model.system.get(key); + return (this[Context.current] ? this[Context.current].domain?.config?.[key.replace(/\./g, '$')] : null) ?? global.Hydro.model.system.get(key); } } export async function apply(ctx: Context) { - Context.service('setting', SettingService); - ctx.setting = new SettingService(ctx); + ctx.plugin(SettingService); logger.info('Ensuring settings'); const system = global.Hydro.model.system; for (const setting of SYSTEM_SETTINGS) { diff --git a/packages/hydrooj/src/model/storage.ts b/packages/hydrooj/src/model/storage.ts index c9cd51f12..72e120ccb 100644 --- a/packages/hydrooj/src/model/storage.ts +++ b/packages/hydrooj/src/model/storage.ts @@ -3,8 +3,8 @@ import { escapeRegExp } from 'lodash'; import moment from 'moment-timezone'; import { nanoid } from 'nanoid'; import type { Readable } from 'stream'; +import { Context } from '../context'; import mime from '../lib/mime'; -import * as bus from '../service/bus'; import db from '../service/db'; import storage from '../service/storage'; import ScheduleModel from './schedule'; @@ -135,26 +135,35 @@ async function cleanFiles() { res = await StorageModel.coll.findOneAndDelete({ autoDelete: { $lte: new Date() } }); } } -ScheduleModel.Worker.addHandler('storage.prune', cleanFiles); -bus.on('ready', async () => { - if (process.env.NODE_APP_INSTANCE !== '0') return; - await db.ensureIndexes( - StorageModel.coll, - { key: { path: 1, autoDelete: 1 }, sparse: true, name: 'autoDelete' }, - ); - if (!await ScheduleModel.count({ type: 'schedule', subType: 'storage.prune' })) { - await ScheduleModel.add({ - type: 'schedule', - subType: 'storage.prune', - executeAfter: moment().startOf('hour').toDate(), - interval: [1, 'hour'], - }); - } -}); -bus.on('domain/delete', async (domainId) => { - const files = await StorageModel.list(`problem/${domainId}`); - await StorageModel.del(files.map((i) => i.path)); -}); + +export function apply(ctx: Context) { + ctx.inject(['worker'], (c) => { + c.worker.addHandler('storage.prune', cleanFiles); + }); + ctx.on('domain/delete', async (domainId) => { + const [problemFiles, contestFiles, trainingFiles] = await Promise.all([ + StorageModel.list(`problem/${domainId}`), + StorageModel.list(`contest/${domainId}`), + StorageModel.list(`training/${domainId}`), + ]); + await StorageModel.del(problemFiles.concat(contestFiles).concat(trainingFiles).map((i) => i.path)); + }); + ctx.on('ready', async () => { + if (process.env.NODE_APP_INSTANCE !== '0') return; + await db.ensureIndexes( + StorageModel.coll, + { key: { path: 1, autoDelete: 1 }, sparse: true, name: 'autoDelete' }, + ); + if (!await ScheduleModel.count({ type: 'schedule', subType: 'storage.prune' })) { + await ScheduleModel.add({ + type: 'schedule', + subType: 'storage.prune', + executeAfter: moment().startOf('hour').toDate(), + interval: [1, 'hour'], + }); + } + }); +} global.Hydro.model.storage = StorageModel; export default StorageModel; diff --git a/packages/hydrooj/src/service/check.ts b/packages/hydrooj/src/service/check.ts index 997e97d8c..8307c4763 100644 --- a/packages/hydrooj/src/service/check.ts +++ b/packages/hydrooj/src/service/check.ts @@ -14,7 +14,7 @@ export class CheckService extends Service { c = {}; addChecker(type: string, checkFunc: CheckItem) { this.checkers[`check${type}`] = checkFunc; - this.caller?.on('dispose', () => { + this[Context.current]?.on('dispose', () => { delete this.checkers[`check${type}`]; }); } @@ -73,6 +73,6 @@ check.addChecker('Setting', async (ctx, log, warn) => { }); export async function apply(ctx: Context) { - Context.service('check', CheckService); + ctx.provide('check', CheckService, true); ctx.check = check; } diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index a83b5081d..81760b6d4 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -479,12 +479,12 @@ class NotFoundHandler extends Handler { } export class RouteService extends Service { - static readonly methods = ['Route', 'Connection', 'withHandlerClass']; private registry = {}; private registrationCount = Counter(); - constructor(ctx) { + constructor(ctx: Context) { super(ctx, 'server', true); + ctx.mixin('server', ['Route', 'Connection', 'withHandlerClass']); } private register(func: typeof Route | typeof Connection, ...args: Parameters) { @@ -498,7 +498,7 @@ export class RouteService extends Service { this.registrationCount[name]++; const res = func(...args); this.ctx.parallel(`handler/register/${name}`, HandlerClass); - this.caller?.on('dispose', () => { + this[Context.current]?.on('dispose', () => { this.registrationCount[name]--; if (!this.registrationCount[name]) delete this.registry[name]; res(); @@ -508,7 +508,7 @@ export class RouteService extends Service { public withHandlerClass(name: string, callback: (HandlerClass: typeof HandlerCommon) => any) { if (this.registry[name]) callback(this.registry[name]); this.ctx.on(`handler/register/${name}`, callback); - this.caller?.on('dispose', () => { + this[Context.current]?.on('dispose', () => { this.ctx.off(`handler/register/${name}`, callback); }); } @@ -531,7 +531,7 @@ declare module '../context' { } export async function apply(pluginContext: Context) { - Context.service('server', RouteService); + pluginContext.provide('server', undefined, true); pluginContext.server = new RouteService(pluginContext); app.keys = system.get('session.keys') as unknown as string[]; if (process.env.HYDRO_CLI) return; diff --git a/packages/hydrooj/src/service/watcher.ts b/packages/hydrooj/src/service/watcher.ts index ec42cee09..63c601a8b 100644 --- a/packages/hydrooj/src/service/watcher.ts +++ b/packages/hydrooj/src/service/watcher.ts @@ -38,10 +38,9 @@ export default class Watcher extends Service { private declined: Set; private stashed = new Set(); - constructor(public ctx: Context, private config: any) { + constructor(public ctx: Context) { super(ctx, 'watcher', true); this.externals = new Set(Object.keys(require.cache)); - Context.service('watcher'); } start() { diff --git a/packages/hydrooj/src/service/worker.ts b/packages/hydrooj/src/service/worker.ts new file mode 100644 index 000000000..3b6e79ac3 --- /dev/null +++ b/packages/hydrooj/src/service/worker.ts @@ -0,0 +1,90 @@ +import moment from 'moment-timezone'; +import { sleep } from '@hydrooj/utils'; +import { Context, Service } from '../context'; +import db from './db'; + +declare module '../context' { + interface Context { + worker: WorkerService; + } +} + +export class WorkerService extends Service { + private handlers: Record = {}; + consuming = true; + running = null; + promise: Promise = null; + coll = db.collection('schedule'); + + constructor(ctx: Context) { + super(ctx, 'worker', true); + this.consume(); + } + + async getFirst() { + if (process.env.CI) return null; + const q = { + executeAfter: { $lt: new Date() }, + type: 'schedule', + subType: { $in: Object.keys(this.handlers) }, + }; + const res = await this.coll.findOneAndDelete(q); + if (res.value) { + this.ctx.logger.debug('%o', res.value); + if (res.value.interval) { + const executeAfter = moment(res.value.executeAfter).add(...res.value.interval).toDate(); + await this.coll.insertOne({ ...res.value, executeAfter }); + } + return res.value; + } + return null; + } + + async consume() { + while (this.consuming) { + try { + // eslint-disable-next-line no-await-in-loop + const doc = await this.getFirst(); + if (!doc) { + // eslint-disable-next-line no-await-in-loop + await sleep(1000); + continue; + } + this.running = doc; + this.ctx.logger.debug('Worker task: %o', doc); + const start = Date.now(); + this.promise = Promise.race([ + this.handlers[doc.subType](doc).catch((e) => { + this.ctx.logger.error('Worker task fail: ', e); + this.ctx.logger.error('%o', doc); + }), + sleep(1200000), + ]); + // eslint-disable-next-line no-await-in-loop + await this.promise; + const spent = Date.now() - start; + if (spent > 500) this.ctx.logger.warn('Slow worker task (%d ms): %o', spent, doc); + this.running = null; + } catch (err) { + this.ctx.logger.error(err); + } + } + } + + public addHandler(type: string, handler: Function) { + this.handlers[type] = handler; + this[Context.current]?.on('dispose', () => { + delete this.handlers[type]; + }); + } + + public async stop() { + this.consuming = false; + await this.promise; + } +} + +export async function apply(ctx: Context) { + ctx.provide('worker', undefined, true); + ctx.worker = new WorkerService(ctx); +} diff --git a/packages/import-hoj/index.ts b/packages/import-hoj/index.ts index 0e8a725a0..ae6077f27 100644 --- a/packages/import-hoj/index.ts +++ b/packages/import-hoj/index.ts @@ -135,7 +135,7 @@ class ImportHojHandler extends Handler { export const name = 'import-hoj'; export async function apply(ctx: Context) { ctx.Route('problem_import_hoj', '/problem/import/hoj', ImportHojHandler, PERM.PERM_CREATE_PROBLEM); - ctx.inject('ProblemAdd', 'problem_import_hoj', { icon: 'copy', text: 'From HOJ Export' }); + ctx.injectUI('ProblemAdd', 'problem_import_hoj', { icon: 'copy', text: 'From HOJ Export' }); ctx.i18n.load('zh', { 'From HOJ Export': '从 HOJ 导入', }); diff --git a/packages/import-qduoj/index.ts b/packages/import-qduoj/index.ts index 8ce6ffcf1..e27b1f19c 100644 --- a/packages/import-qduoj/index.ts +++ b/packages/import-qduoj/index.ts @@ -150,7 +150,7 @@ class ImportQduojHandler extends Handler { export const name = 'import-qduoj'; export async function apply(ctx: Context) { ctx.Route('problem_import_qduoj', '/problem/import/qduoj', ImportQduojHandler, PERM.PERM_CREATE_PROBLEM); - ctx.inject('ProblemAdd', 'problem_import_qduoj', { icon: 'copy', text: 'From QDUOJ Export' }); + ctx.injectUI('ProblemAdd', 'problem_import_qduoj', { icon: 'copy', text: 'From QDUOJ Export' }); ctx.i18n.load('zh', { 'From QDUOJ Export': '从 QDUOJ 导入', }); diff --git a/packages/prom-client/metrics.ts b/packages/prom-client/metrics.ts index dff886045..f523adbcc 100644 --- a/packages/prom-client/metrics.ts +++ b/packages/prom-client/metrics.ts @@ -9,8 +9,6 @@ declare module 'hydrooj' { } } -Context.service('metrics'); - export function createRegistry(ctx: Context) { const registry = new Registry(); @@ -77,6 +75,7 @@ export function createRegistry(ctx: Context) { collectDefaultMetrics({ register: registry }); + ctx.provide('metrics'); ctx.metrics = registry; return registry; } diff --git a/packages/ui-default/backendlib/builder.ts b/packages/ui-default/backendlib/builder.ts index c3acbcc5a..15d328852 100644 --- a/packages/ui-default/backendlib/builder.ts +++ b/packages/ui-default/backendlib/builder.ts @@ -138,7 +138,7 @@ export async function apply(ctx: Context) { ctx.on('app/started', buildUI); const debouncedBuildUI = debounce(buildUI, 2000, { trailing: true }); const triggerHotUpdate = (path?: string) => { - if (path && !path.includes('/ui-default/') && !path.includes('/public/')) return; + if (path && !path.includes('/ui-default/') && !path.includes('/public/') && !path.includes('/frontend/')) return; debouncedBuildUI(); }; ctx.on('system/setting', () => triggerHotUpdate()); diff --git a/packages/ui-default/components/scratchpad/index.tsx b/packages/ui-default/components/scratchpad/index.tsx index c10f35af0..e20e18095 100644 --- a/packages/ui-default/components/scratchpad/index.tsx +++ b/packages/ui-default/components/scratchpad/index.tsx @@ -7,7 +7,7 @@ import type * as monaco from 'monaco-editor'; import React from 'react'; import { useDispatch, useSelector, useStore } from 'react-redux'; import Dom from 'vj/components/react/DomComponent'; -import { Context, ctx, Service } from 'vj/context'; +import { ctx, Service } from 'vj/context'; import ScratchpadEditor from './ScratchpadEditorContainer'; import ScratchpadPretest from './ScratchpadPretestContainer'; import ScratchpadRecords from './ScratchpadRecordsContainer'; @@ -54,7 +54,6 @@ class ScratchpadService extends Service { rerenderCallback?.(); } } -Context.service('scratchpad', ScratchpadService); declare module '../../context' { interface Context { scratchpad: ScratchpadService; @@ -63,6 +62,7 @@ declare module '../../context' { export default function ScratchpadContainer() { const store = useStore(); + ctx.provide('scratchpad'); ctx.scratchpad ||= new ScratchpadService(store); const [, updateState] = React.useState(); const forceUpdate = React.useCallback(() => updateState({}), []); diff --git a/packages/ui-default/components/zipDownloader/index.ts b/packages/ui-default/components/zipDownloader/index.ts index 7a7b642d1..daab9a7d8 100644 --- a/packages/ui-default/components/zipDownloader/index.ts +++ b/packages/ui-default/components/zipDownloader/index.ts @@ -19,8 +19,8 @@ streamsaver.mitm = `${window.isSecureContext ? '' : 'https://hydro.ac'}/streamsa const waitForWritableStream = window.WritableStream ? Promise.resolve() - : import('web-streams-polyfill/dist/ponyfill.es6').then(({ WritableStream }) => { - window.WritableStream = WritableStream; + : import('web-streams-polyfill').then(({ WritableStream }) => { + window.WritableStream = WritableStream as any; streamsaver.WritableStream = window.WritableStream; }); diff --git a/packages/ui-default/context.ts b/packages/ui-default/context.ts index 04a13ba53..237ac5efe 100644 --- a/packages/ui-default/context.ts +++ b/packages/ui-default/context.ts @@ -1,47 +1,38 @@ import * as cordis from 'cordis'; import type { EventMap } from './api'; -export interface Events extends cordis.Events, EventMap { -} +export interface Events extends cordis.Events, EventMap { } + +export type { Disposable, ScopeStatus, Plugin } from 'cordis'; export interface Context { - [Context.events]: Events; - setTimeout: typeof setTimeout; - setInterval: typeof setInterval; - api: ApiMixin; + [Context.events]: Events; + broadcast: Context['emit']; } export class Context extends cordis.Context { - static readonly session = Symbol('session'); + /** @deprecated use `ctx.root` instead */ + get app() { + return this.root; + } + + /** @deprecated use `root.config` instead */ + get options() { + return this.root.config; + } } export namespace Context { - export interface Config extends cordis.Context.Config { } + export type Associate

= cordis.Context.Associate; } export type MainScope = cordis.MainScope; export type EffectScope = cordis.EffectScope; export type ForkScope = cordis.ForkScope; -export type Plugin = cordis.Plugin; -export const Service = cordis.Service; -export namespace Plugin { - export type Function = cordis.Plugin.Function; - export type Constructor = cordis.Plugin.Constructor; - export type Object = cordis.Plugin.Object; -} -const T = any>(origFunc: F, disposeFunc?) => - function method(this: cordis.Service, ...args: Parameters) { - const res = origFunc(...args); - this.caller?.on('dispose', () => (disposeFunc ? disposeFunc(res) : res())); - }; -export class ApiMixin extends Service { - static readonly methods = ['setInterval', 'setTimeout']; - setInterval = T(setInterval, clearInterval); - setTimeout = T(setTimeout, clearTimeout); - constructor(ctx) { - super(ctx, 'api', true); +export abstract class Service extends cordis.Service { + [cordis.Service.setup]() { + this.ctx = new Context() as C; } } -Context.service('api', ApiMixin); export const ctx = new Context(); diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 7c38bdcb6..23c3a9f56 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -14,15 +14,15 @@ "cli": false }, "devDependencies": { - "@blueprintjs/core": "^5.9.1", - "@fontsource/dm-mono": "^5.0.18", - "@fontsource/fira-code": "^5.0.16", - "@fontsource/inconsolata": "^5.0.16", - "@fontsource/jetbrains-mono": "^5.0.18", - "@fontsource/pt-mono": "^5.0.8", - "@fontsource/roboto-mono": "^5.0.16", - "@fontsource/source-code-pro": "^5.0.16", - "@fontsource/ubuntu-mono": "^5.0.18", + "@blueprintjs/core": "^5.10.0", + "@fontsource/dm-mono": "^5.0.19", + "@fontsource/fira-code": "^5.0.17", + "@fontsource/inconsolata": "^5.0.17", + "@fontsource/jetbrains-mono": "^5.0.19", + "@fontsource/pt-mono": "^5.0.12", + "@fontsource/roboto-mono": "^5.0.17", + "@fontsource/source-code-pro": "^5.0.17", + "@fontsource/ubuntu-mono": "^5.0.19", "@hydrooj/utils": "workspace:*", "@simplewebauthn/browser": "7.4.0", "@svgr/webpack": "^8.1.0", @@ -34,21 +34,21 @@ "@types/nunjucks": "^3.2.6", "@types/pickadate": "^3.5.35", "@types/qrcode": "^1.5.5", - "@types/react": "^18.2.57", + "@types/react": "^18.2.63", "@types/react-dom": "^18.2.19", "@types/redux-logger": "^3.0.13", "@types/webpack-env": "^1.18.4", "@vscode/codicons": "^0.0.35", "ajv": "^8.12.0", "allotment": "^1.20.0", - "autoprefixer": "^10.4.17", - "browser-update": "^3.3.44", + "autoprefixer": "^10.4.18", + "browser-update": "^3.3.51", "chalk": "^5.3.0", "classnames": "^2.5.1", "clean-webpack-plugin": "^4.0.0", "clipboard": "^2.0.11", "copy-webpack-plugin": "^12.0.2", - "cordis": "2.8.8", + "cordis": "3.13.3", "css-loader": "^6.10.0", "diff": "^5.2.0", "diff-dom": "^5.1.2", @@ -69,7 +69,7 @@ "jquery.easing": "^1.4.1", "jquery.transit": "^0.9.12", "matchmedia-polyfill": "^0.3.2", - "mini-css-extract-plugin": "^2.8.0", + "mini-css-extract-plugin": "^2.8.1", "moment": "^2.30.1", "monaco-editor": "0.46.0", "monaco-editor-nls": "^3.1.0", @@ -107,7 +107,7 @@ "slideout": "^1.0.1", "sticky-kit": "^1.1.3", "style-loader": "^3.3.4", - "stylus": "^0.62.0", + "stylus": "^0.63.0", "stylus-loader": "7.1.2", "tether": "1.4.7", "tether-drop": "^1.4.2", @@ -115,9 +115,9 @@ "timeago-react": "^3.0.6", "timeago.js": "^4.0.2", "ts-loader": "^9.5.1", - "vditor": "^3.9.9", + "vditor": "^3.10.1", "vinyl-buffer": "^1.0.1", - "web-streams-polyfill": "^3.3.3", + "web-streams-polyfill": "^4.0.0", "webpack": "^5.90.3", "webpack-bundle-analyzer": "^4.10.1", "webpack-dev-server": "^5.0.2", @@ -143,6 +143,6 @@ "nunjucks": "^3.2.4", "schemastery-jsonschema": "^1.1.0", "streamsaver": "^2.0.6", - "xss": "^1.0.14" + "xss": "^1.0.15" } } diff --git a/packages/ui-default/pages/home_messages.page.tsx b/packages/ui-default/pages/home_messages.page.tsx index 7555bd17a..e18a612db 100644 --- a/packages/ui-default/pages/home_messages.page.tsx +++ b/packages/ui-default/pages/home_messages.page.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import VjNotification from 'vj/components/notification'; import selectUser from 'vj/components/selectUser'; -import { Context, ctx, Service } from 'vj/context'; +import { ctx, Service } from 'vj/context'; import { NamedPage } from 'vj/misc/Page'; import { api, gql, loadReactRedux } from 'vj/utils'; @@ -12,7 +12,7 @@ class MessagePadService extends Service { super(ctx, 'messagepad', true); } } -Context.service('messagepad', MessagePadService); + declare module '../context' { interface Context { messagepad: MessagePadService; @@ -43,6 +43,7 @@ const page = new NamedPage('home_messages', () => { reduxStore = store; (window as any).store = reduxStore; + ctx.provide('messagepad'); ctx.messagepad = new MessagePadService(store, WebSocket); const sock = new WebSocket(`${UiContext.ws_prefix}home/messages-conn`); diff --git a/packages/vjudge/src/index.ts b/packages/vjudge/src/index.ts index 1e6f33f15..d90a536fd 100644 --- a/packages/vjudge/src/index.ts +++ b/packages/vjudge/src/index.ts @@ -171,7 +171,7 @@ class VJudgeService extends Service { if (account.enableOn && !account.enableOn.includes(os.hostname())) continue; this.pool[`${account.type}/${account.handle}`] = new AccountService(provider, account); } - this.caller?.on('dispose', () => { + this[Context.current]?.on('dispose', () => { // TODO dispose session }); } @@ -193,7 +193,6 @@ export { BasicFetcher } from './fetch'; export { VERDICT } from './verdict'; export * from './interface'; -Context.service('vjudge', VJudgeService); export const name = 'vjudge'; export async function apply(ctx: Context) { if (process.env.NODE_APP_INSTANCE !== '0') return; @@ -205,6 +204,7 @@ export async function apply(ctx: Context) { vjudge.addProvider(k, v); } // }); + ctx.provide('vjudge'); ctx.vjudge = vjudge; ctx.check.addChecker('Vjudge', async (_ctx, log, warn, error) => { const working = [];