use cordis@4

This commit is contained in:
undefined committed 2025-02-19 05:28:12 +08:00
1 parent 085b6c9b4c
commit 9264f340cb
18 files changed
+284 -168

No files matched your search

+7 -5
View File
@@ -16,7 +16,7 @@
"@aws-sdk/lib-storage": "3.726.1",
"@aws-sdk/s3-presigned-post": "3.726.1",
"@aws-sdk/s3-request-presigner": "3.726.1",
"@graphql-tools/schema": "^10.0.16",
"@graphql-tools/schema": "^10.0.18",
"@hydrooj/common": "workspace:^",
"@hydrooj/framework": "workspace:^",
"@hydrooj/utils": "workspace:^",
@@ -25,7 +25,7 @@
"@yarnpkg/libzip": "^3.1.0",
"adm-zip": "0.5.14",
"cac": "^6.7.14",
"cordis": "3.18.1",
"cordis": "4.0.0-alpha.0",
"csv-stringify": "^6.5.2",
"emoji-regex": "^10.4.0",
"emojis-list": "2.1.0",
@@ -41,22 +41,24 @@
"moment-timezone": "^0.5.47",
"mongodb": "^5.9.2",
"mongodb-uri": "^0.9.7",
"nanoid": "^5.0.9",
"nanoid": "^5.1.0",
"nodemailer": "^6.10.0",
"notp": "^2.0.3",
"p-queue": "^8.1.0",
"sanitize-filename": "^1.6.3",
"saslprep": "^1.0.3",
"schemastery": "^3.15.0",
"schemastery": "^3.16.0",
"semver": "^7.7.1",
"shorty.js": "^1.0.1",
"superagent": "^10.1.1",
"tar": "6.2.1",
"thirty-two": "^1.0.2",
"ua-parser-js": "^2.0.1"
"ua-parser-js": "^2.0.2"
},
"devDependencies": {
"@babel/code-frame": "^7.26.2",
"@types/adm-zip": "^0.4.34",
"@types/babel__code-frame": "^7.0.6",
"@types/fs-extra": "^11.0.4",
"@types/js-yaml": "^4.0.9",
"@types/koa-static-cache": "^5.1.4",
+99 -11
View File
@@ -24,10 +24,8 @@ function provideModule<T extends keyof ModuleInterfaces>(type: T, id: string, mo
}
export type EffectScope = cordis.EffectScope<Context>;
export type ForkScope = cordis.ForkScope<Context>;
export type MainScope = cordis.MainScope<Context>;
export type { Disposable, ScopeStatus, Plugin } from 'cordis';
export { Disposable, Plugin, ScopeStatus } from 'cordis';
export interface Context extends cordis.Context, Pick<WebService, 'Route' | 'Connection' | 'withHandlerClass'> {
// @ts-ignore
@@ -40,12 +38,15 @@ export interface Context extends cordis.Context, Pick<WebService, 'Route' | 'Con
injectUI: typeof inject;
broadcast: Context['emit'];
geoip?: GeoIP;
// TODO: move to @cordisjs/plugin-timer
setTimeout(callback: () => void, delay: number): () => void
setInterval(callback: () => void, delay: number): () => void
sleep(delay: number): Promise<void>
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F>
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F>
}
export abstract class Service<T = any, C extends Context = Context> extends cordis.Service<T, C> {
[cordis.Service.setup]() {
this.ctx = new Context() as C;
}
export abstract class Service extends cordis.Service<Context> {
}
const T = <F extends (...args: any[]) => any>(origFunc: F, disposeFunc?) =>
@@ -56,7 +57,7 @@ const T = <F extends (...args: any[]) => any>(origFunc: F, disposeFunc?) =>
});
};
export class ApiMixin extends Service {
export class ApiMixin extends cordis.Service {
addScript = T(addScript);
setImmediate = T(setImmediate, clearImmediate);
provideModule = T(provideModule);
@@ -65,17 +66,104 @@ export class ApiMixin extends Service {
this.ctx.emit('bus/broadcast', event, payload, process.env.TRACE_BROADCAST ? new Error().stack : null);
constructor(ctx) {
super(ctx, '$api', true);
super(ctx, '$api');
ctx.mixin('$api', ['addScript', 'setImmediate', 'provideModule', 'injectUI', 'broadcast']);
}
}
type WithDispose<T> = T & { dispose: () => void };
class TimerService extends Service {
constructor(ctx) {
super(ctx, '$timer');
ctx.mixin('$timer', ['setTimeout', 'setInterval', 'sleep', 'throttle', 'debounce']);
}
setTimeout(callback: () => void, delay: number) {
const dispose = this.ctx.effect(() => {
const timer = setTimeout(() => {
dispose();
callback();
}, delay);
return () => clearTimeout(timer);
});
return dispose;
}
setInterval(callback: () => void, delay: number) {
return this.ctx.effect(() => {
const timer = setInterval(callback, delay);
return () => clearInterval(timer);
});
}
sleep(delay: number) {
const caller = this.ctx;
return new Promise<void>((resolve, reject) => {
const dispose1 = this.setTimeout(() => {
dispose1();
dispose2(); // eslint-disable-line @typescript-eslint/no-use-before-define
resolve();
}, delay);
const dispose2 = caller.on('dispose', () => {
dispose1();
dispose2();
reject(new Error('Context has been disposed'));
});
});
}
private createWrapper(callback: (args: any[], check: () => boolean) => any, isDisposed = false) {
this.ctx.scope.assertActive();
let timer: number | NodeJS.Timeout | undefined;
const dispose = () => {
isDisposed = true;
remove(); // eslint-disable-line @typescript-eslint/no-use-before-define
clearTimeout(timer);
};
const wrapper: any = (...args: any[]) => {
clearTimeout(timer);
timer = callback(args, () => !isDisposed && this.ctx.scope.active);
};
wrapper.dispose = dispose;
const remove = this.ctx.scope.disposables.push(dispose);
return wrapper;
}
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
let lastCall = -Infinity;
const execute = (...args: any[]) => {
lastCall = Date.now();
callback(...args);
};
return this.createWrapper((args, isActive) => { // eslint-disable-line consistent-return
const now = Date.now();
const remaining = delay - (now - lastCall);
if (remaining <= 0) {
execute(...args);
} else if (isActive()) {
return setTimeout(execute, remaining, ...args);
}
}, noTrailing);
}
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
return this.createWrapper((args, isActive) => {
if (!isActive()) return;
return setTimeout(callback, delay, ...args); // eslint-disable-line consistent-return
});
}
}
export class Context extends cordis.Context {
domain?: DomainDoc;
constructor(config: {} = {}) {
super(config);
constructor() {
super();
this.plugin(ApiMixin);
this.plugin(TimerService);
}
}
-3
View File
@@ -114,8 +114,6 @@ export async function load(ctx: Context) {
await require('../settings').loadConfig();
await require('../model/system').runConfig();
await require('../service/storage').loadStorageService();
await ctx.root.start();
await ctx.lifecycle.flush();
require('../lib/index');
await Promise.all([
lib(pending, fail, ctx),
@@ -132,6 +130,5 @@ export async function load(ctx: Context) {
ctx.loader.reloadPlugin(ctx, path.resolve(scriptDir, h), {}, `hydrooj/script/${h.split('.')[0]}`);
}
await script(pending, fail, ctx);
await ctx.lifecycle.flush();
await cli();
}
-8
View File
@@ -42,15 +42,11 @@ export async function apply(ctx: Context) {
await storage.loadStorageService();
// Make sure everything is ready and then start main entry
if (argv.options.watch) ctx.plugin(require('../service/watcher').default, {});
await ctx.root.start();
await ctx.lifecycle.flush();
await require('../service/worker').apply(ctx);
await require('../service/server').apply(ctx);
await require('../service/api').apply(ctx);
await ctx.lifecycle.flush();
require('../lib/index');
await lib(pending, fail, ctx);
await ctx.lifecycle.flush();
await setting(pending, fail, require('../model/setting'));
ctx.plugin(require('../service/monitor'));
@@ -58,7 +54,6 @@ export async function apply(ctx: Context) {
await service(pending, fail, ctx);
await builtinModel(ctx);
await model(pending, fail, ctx);
await ctx.lifecycle.flush();
const handlerDir = path.resolve(__dirname, '..', 'handler');
const handlers = await fs.readdir(handlerDir);
@@ -68,14 +63,11 @@ export async function apply(ctx: Context) {
ctx.plugin(require('../service/migration').default);
await handler(pending, fail, ctx);
await addon(pending, fail, ctx);
await ctx.lifecycle.flush();
const scriptDir = path.resolve(__dirname, '..', 'script');
for (const h of await fs.readdir(scriptDir)) {
ctx.loader.reloadPlugin(ctx, path.resolve(scriptDir, h), {}, `hydrooj/script/${h.split('.')[0]}`);
}
await ctx.lifecycle.flush();
await script(pending, fail, ctx);
await ctx.lifecycle.flush();
await ctx.parallel('app/started');
if (process.env.NODE_APP_INSTANCE === '0') {
await new Promise((resolve, reject) => {
+1 -1
View File
@@ -675,7 +675,7 @@ export class ContestScoreboardHandler extends ContestDetailBaseHandler {
class ScoreboardService extends Service {
views: Record<string, ScoreboardView<any>> = {};
constructor(ctx: Context) {
super(ctx, 'scoreboard', true);
super(ctx, 'scoreboard');
ctx.set('scoreboard', this);
}
+4 -6
View File
@@ -898,9 +898,8 @@ export class ProblemSolutionHandler extends ProblemDetailHandler {
@param('content', Types.Content)
async postEditReply(domainId: string, psid: ObjectId, psrid: ObjectId, content: string) {
const [psdoc, psrdoc] = await solution.getReply(domainId, psid, psrid);
if ((!psdoc) || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(domainId, psid);
if (!(this.user.own(psrdoc)
&& this.user.hasPerm(PERM.PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF))) {
if (!psdoc || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(domainId, psid);
if (!this.user.own(psrdoc) || !this.user.hasPerm(PERM.PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF)) {
throw new PermissionError(PERM.PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF);
}
await solution.editReply(domainId, psid, psrid, content);
@@ -911,9 +910,8 @@ export class ProblemSolutionHandler extends ProblemDetailHandler {
@param('psrid', Types.ObjectId)
async postDeleteReply(domainId: string, psid: ObjectId, psrid: ObjectId) {
const [psdoc, psrdoc] = await solution.getReply(domainId, psid, psrid);
if ((!psdoc) || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(psid);
if (!(this.user.own(psrdoc)
&& this.user.hasPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION_REPLY_SELF))) {
if (!psdoc || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(psid);
if (!this.user.own(psrdoc) || !this.user.hasPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION_REPLY_SELF)) {
this.checkPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION_REPLY);
}
await solution.delReply(domainId, psid, psrid);
+1 -1
View File
@@ -33,5 +33,5 @@ if (!global.Hydro) {
}
global.app = new (require('./context').Context)();
process.on('exit', () => {
app.stop();
});
+46 -49
View File
@@ -14,7 +14,7 @@ import './ui';
import './lib/i18n';
import { Logger } from './logger';
import { Context } from './context';
import { Context, Service, ScopeStatus } from './context';
// eslint-disable-next-line import/no-duplicates
import { sleep, unwrapExports } from './utils';
import { PRIV } from './model/builtin';
@@ -51,71 +51,72 @@ export function resolveConfig(plugin: any, config: any) {
return config;
}
const timeout = Symbol.for('loader.timeout');
const showLoadTime = argv.options.showLoadTime;
export class Loader {
static readonly Record = Symbol.for('loader.record');
public app: Context;
export class Loader extends Service {
public state: Record<string, any> = Object.create(null);
public config: {};
public suspend = false;
public cache: Record<string, string> = Object.create(null);
// public warnings: Record<string, string> = Object.create(null);
unloadPlugin(ctx: Context, key: string) {
const fork = ctx.state[Loader.Record][key];
constructor(private app: Context) {
super(app, 'loader');
}
[Service.setup]() {
this.app.on('app/started', () => {
this.ctx.setInterval(async () => {
const pending = Object.entries(this.state).filter((v) => v[1].status === ScopeStatus.PENDING);
if (pending.length) {
logger.warn('Plugins are still pending: %s', pending.map((v) => v[0]).join(', '));
for (const [key, value] of pending) {
logger.warn('Plugin %s is still pending', key);
console.log(value);
}
}
const loading = Object.entries(this.state).filter((v) => v[1].status === ScopeStatus.LOADING);
if (loading.length) {
logger.warn('Plugins are still loading: %s', loading.map((v) => v[0]).join(', '));
for (const [key, value] of loading) {
logger.warn('Plugin %s is still loading', key);
console.log(value);
}
}
const failed = Object.entries(this.state).filter((v) => v[1].status === ScopeStatus.FAILED);
if (failed.length) {
logger.warn('Plugins failed to load: %s', failed.map((v) => v[0]).join(', '));
for (const [key, value] of failed) {
logger.warn('Plugin %s failed to load', key);
console.log(value);
}
}
}, 10000);
});
}
unloadPlugin(key: string) {
const fork = this.state[key];
if (fork) {
fork.dispose();
delete ctx.state[Loader.Record][key];
delete this.state[key];
logger.info('unload plugin %c', key);
}
}
async reloadPlugin(parent: Context, key: string, config: any, asName = '') {
let fork = parent.state[Loader.Record]?.[key];
let fork = this.state[key];
if (fork) {
logger.info('reload plugin %c', key.split('node_modules').pop());
fork.update(config);
} else {
logger.info('apply plugin %c', key.split('node_modules').pop());
let plugin = await this.resolvePlugin(key);
const plugin = await this.resolvePlugin(key);
if (!plugin) return;
resolveConfig(plugin, config);
if (asName) plugin.name = asName;
// fork = parent.plugin(plugin, this.interpolate(config));
if (plugin.apply) {
const original = plugin.apply;
const apply = (...args) => {
const start = Date.now();
const result = Promise.resolve()
.then(() => original(...args))
.catch((e) => logger.error(e));
Promise.race([
result,
new Promise((resolve) => {
setTimeout(() => resolve(timeout), 10000);
}),
]).then((t) => {
if (t === timeout) {
logger.warn('Plugin %s took too long to load', key);
}
});
if (showLoadTime && (typeof showLoadTime !== 'number' || Date.now() - start > showLoadTime)) {
logger.info('Plugin %s loaded in %dms', key, Date.now() - start);
}
return result;
};
plugin = Object.create(plugin);
Object.defineProperty(plugin, 'apply', {
writable: true,
value: apply,
enumerable: true,
});
}
fork = parent.plugin(plugin, config);
if (!fork) return;
parent.state[Loader.Record] ||= Object.create(null);
parent.state[Loader.Record][key] = fork;
this.state[key] = fork;
}
return fork;
}
@@ -135,11 +136,7 @@ export class Loader {
}
}
const loader = new Loader();
app.provide('loader');
app.loader = loader;
loader.app = app;
app.state[Loader.Record] = Object.create(null);
app.plugin(Loader);
function preload() {
for (const a of [path.resolve(__dirname, '..'), ...getAddons()]) {
+1 -1
View File
@@ -315,7 +315,7 @@ export class SettingService extends Service {
DomainUserSetting = T(DomainUserSetting);
SystemSetting = T(SystemSetting);
constructor(ctx: Context) {
super(ctx, 'setting', true);
super(ctx, 'setting');
}
get(key: string) {
+74 -50
View File
@@ -2,13 +2,52 @@
/* eslint-disable consistent-return */
/* eslint-disable import/no-dynamic-require */
/* eslint-disable @typescript-eslint/no-shadow */
import { readFileSync } from 'fs';
import { relative, resolve, sep } from 'path';
import { codeFrameColumns } from '@babel/code-frame';
import { FSWatcher, watch } from 'chokidar';
import { debounce } from 'lodash';
import { Context, MainScope, Service } from '../context';
import { BuildFailure } from 'esbuild';
import { Context, Plugin, Service } from '../context';
import { Logger } from '../logger';
import { unwrapExports } from '../utils';
declare module '../context' {
interface Events {
'hmr/reload': (reloads: Map<Plugin, { filename: string, runtime: Plugin.Runtime<Context> }>) => void;
}
}
function isBuildFailure(e: any): e is BuildFailure {
return Array.isArray(e?.errors) && e.errors.every((error: any) => error.text);
}
export function handleError(ctx: Context, e: any) {
if (!isBuildFailure(e)) {
ctx.logger.warn(e);
return;
}
for (const error of e.errors) {
if (!error.location) {
ctx.logger.warn(error.text);
continue;
}
try {
const { file, line, column } = error.location;
const source = readFileSync(file, 'utf8');
const formatted = codeFrameColumns(source, {
start: { line, column },
}, {
highlightCode: true,
message: error.text,
});
ctx.logger.warn(`File: ${file}:${line}:${column}\n${formatted}`);
} catch (e) {
ctx.logger.warn(e);
}
}
}
function loadDependencies(filename: string, ignored: Set<string>) {
const dependencies = new Set<string>();
function traverse({ filename, children }: NodeJS.Module) {
@@ -22,14 +61,6 @@ function loadDependencies(filename: string, ignored: Set<string>) {
const logger = new Logger('watch');
function coerce(val: any) {
// resolve error when stack is undefined, e.g. axios error with status code 401
const { message, stack } = val instanceof Error && val.stack ? val : new Error(val as any);
const lines = stack.split('\n');
const index = lines.findIndex((line) => line.endsWith(message));
return lines.slice(index).join('\n');
}
export default class Watcher extends Service {
private root: string;
private watcher: FSWatcher;
@@ -39,7 +70,7 @@ export default class Watcher extends Service {
private stashed = new Set<string>();
constructor(public ctx: Context) {
super(ctx, 'watcher', true);
super(ctx, 'watcher');
this.externals = new Set(Object.keys(require.cache));
}
@@ -48,7 +79,6 @@ export default class Watcher extends Service {
const roots = [this.root];
if (process.env.WATCH_ROOT) roots.push(process.env.WATCH_ROOT);
this.watcher = watch(roots, {
...this.config,
ignored: (file) => [
'node_modules', '.git', 'logs', '.cache', '.yarn', 'tsconfig.tsbuildinfo',
].some((rule) => file.startsWith(`${rule}/`) || file.endsWith(`/${rule}`) || file.includes(`/${rule}/`)),
@@ -56,7 +86,7 @@ export default class Watcher extends Service {
logger.info(`Start watching changes in ${this.root}`);
// files independent from any plugins will trigger a full reload
const triggerLocalReload = debounce(() => this.triggerLocalReload(), 1000);
const triggerLocalReload = this.ctx.debounce(() => this.triggerLocalReload(), 1000);
this.watcher.on('change', (path) => {
if (path.includes(`${sep}.`)) return;
@@ -140,14 +170,13 @@ export default class Watcher extends Service {
}
private triggerLocalReload() {
const start = Date.now();
this.analyzeChanges();
/** plugins pending classification */
const pending = new Map<string, MainScope>();
const pending = new Map<string, Plugin.Runtime<Context>>();
/** plugins that should be reloaded */
const reloads = new Map<MainScope, string>();
const reloads = new Map<Plugin, { filename: string, runtime: Plugin.Runtime<Context> }>();
// we assume that plugin entry files are "atomic"
// that is, reloading them will not cause any other reloads
@@ -173,25 +202,13 @@ export default class Watcher extends Service {
for (const dep of dependencies) this.accepted.add(dep);
// prepare for reload
let isMarked = false;
const visited = new Set<MainScope>();
const queued = [runtime];
while (queued.length) {
const runtime = queued.shift();
if (visited.has(runtime)) continue;
visited.add(runtime);
if (reloads.has(runtime)) {
isMarked = true;
break;
}
for (const state of runtime.children) {
queued.push(state.runtime);
}
}
if (!isMarked) reloads.set(runtime, filename);
reloads.set(runtime.plugin, {
filename,
runtime,
});
}
const backup: Record<string, NodeJS.Module> = {};
const backup: Record<string, NodeJS.Module> = Object.create(null);
for (const filename of this.accepted) {
backup[filename] = require.cache[filename];
delete require.cache[filename];
@@ -204,54 +221,61 @@ export default class Watcher extends Service {
}
logger.debug('Will reload the following file(s): %o', this.accepted.keys());
const attempts = {};
const attempts: Record<string, any> = {};
try {
for (const [, filename] of reloads) {
for (const [, { filename }] of reloads) {
attempts[filename] = unwrapExports(require(filename));
}
} catch (err) {
logger.warn(err);
} catch (e) {
handleError(this.ctx, e);
return rollback();
}
const reload = (plugin: any, runtime?: Plugin.Runtime) => {
if (!runtime) return;
for (const oldFiber of runtime.scopes) {
oldFiber.parent.plugin(plugin, oldFiber.config);
}
};
try {
for (const [runtime, filename] of reloads) {
for (const [plugin, { filename, runtime }] of reloads) {
const path = relative(this.root, filename);
const states = runtime.children.slice();
try {
this.ctx.registry.delete(runtime.plugin);
this.ctx.registry.delete(plugin);
} catch (err) {
logger.warn(`failed to dispose plugin at %c\n${coerce(err)}`, path);
logger.warn('failed to dispose plugin at %c', path);
logger.warn(err);
}
try {
const plugin = attempts[filename];
for (const state of states) {
state.parent.plugin(plugin, state.config);
}
reload(attempts[filename], runtime);
logger.info('reload plugin at %c', path);
} catch (err) {
logger.warn(`failed to reload plugin at %c\n${coerce(err)}`, path);
logger.warn('failed to reload plugin at %c', path);
logger.warn(err);
throw err;
}
}
} catch {
// rollback require.cache and plugin states
rollback();
for (const [runtime, filename] of reloads) {
for (const [plugin, { filename, runtime }] of reloads) {
try {
this.ctx.registry.delete(attempts[filename]);
runtime.parent.plugin(runtime.plugin, runtime.config);
reload(plugin, runtime);
} catch (err) {
logger.warn(err);
}
}
logger.warn('Rolling back changes');
return;
}
// emit reload event on success
this.ctx.emit('hmr/reload', reloads);
// reset stashed files
this.stashed = new Set();
logger.success('Reload done in %d ms', Date.now() - start);
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ export class WorkerService extends Service {
coll = db.collection('schedule');
constructor(ctx: Context) {
super(ctx, 'worker', true);
super(ctx, 'worker');
this.consume();
}