@halooj/framework 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.ts ADDED
@@ -0,0 +1,925 @@
1
+ /* eslint-disable ts/no-unsafe-declaration-merging */
2
+ import http from 'http';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+ import { PassThrough } from 'stream';
6
+ import type { } from '@cordisjs/plugin-timer';
7
+ import { Context as CordisContext, Service } from 'cordis';
8
+ import type { Files } from 'formidable';
9
+ import fs from 'fs-extra';
10
+ import Koa from 'koa';
11
+ import Body from 'koa-body';
12
+ import Compress from 'koa-compress';
13
+ import Schema from 'schemastery';
14
+ import { Shorty } from 'shorty.js';
15
+ import { WebSocket, WebSocketServer } from 'ws';
16
+ import {
17
+ Counter, errorMessage, isClass, Logger, parseMemoryMB,
18
+ } from '@halooj/utils/lib/utils';
19
+ import base from './base';
20
+ import * as decorators from './decorators';
21
+ import {
22
+ CsrfTokenError, HydroError, InvalidOperationError,
23
+ MethodNotAllowedError, NotFoundError, UserFacingError,
24
+ } from './error';
25
+ import type { KnownHandlers } from './interface';
26
+ import { Router } from './router';
27
+ import serializer from './serializer';
28
+
29
+ export { WebSocket, WebSocketServer } from 'ws';
30
+
31
+ export const kHandler = Symbol.for('hydro.handler');
32
+
33
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
34
+ export function encodeRFC5987ValueChars(str: string) {
35
+ return (
36
+ encodeURIComponent(str)
37
+ // Note that although RFC3986 reserves "!", RFC5987 does not,
38
+ // so we do not need to escape it
39
+ .replace(/['()]/g, escape) // i.e., %27 %28 %29
40
+ .replace(/\*/g, '%2A')
41
+ // The following are not required for percent-encoding per RFC5987,
42
+ // so we can allow for a little better readability over the wire: |`^
43
+ .replace(/%(?:7C|60|5E)/g, unescape)
44
+ );
45
+ }
46
+
47
+ async function forkContextWithScope(ctx: CordisContext) {
48
+ const scope = ctx.plugin(() => { });
49
+ await scope;
50
+ const dispose = () => scope.dispose();
51
+ return {
52
+ scope,
53
+ ctx: scope.ctx,
54
+ dispose,
55
+ [Symbol.asyncDispose]: dispose,
56
+ };
57
+ }
58
+
59
+ export interface HydroRequest {
60
+ method: string;
61
+ host: string;
62
+ hostname: string;
63
+ ip: string;
64
+ headers: Koa.Request['headers'];
65
+ cookies: any;
66
+ body: any;
67
+ files: Record<string, import('formidable').File>;
68
+ query: any;
69
+ querystring: string;
70
+ path: string;
71
+ originalPath: string;
72
+ params: any;
73
+ referer: string;
74
+ json: boolean;
75
+ websocket: boolean;
76
+ }
77
+ export interface HydroResponse {
78
+ body: any;
79
+ type: string;
80
+ status: number;
81
+ template?: string;
82
+ /**
83
+ * If set, and pjax content was request from client,
84
+ * The template will be used for rendering.
85
+ */
86
+ pjax?: string | (readonly [string, Record<string, any>])[];
87
+ redirect?: string;
88
+ disposition?: string;
89
+ etag?: string;
90
+ attachment: (name: string, stream?: any) => void;
91
+ addHeader: (name: string, value: string) => void;
92
+ }
93
+ interface HydroContext {
94
+ request: HydroRequest;
95
+ response: HydroResponse;
96
+ args: Record<string, any>;
97
+ UiContext: Record<string, any>;
98
+ domain: { _id: string };
99
+ user: { _id: number };
100
+ }
101
+ export type KoaContext = Koa.Context & {
102
+ HydroContext: HydroContext;
103
+ handler: any;
104
+ request: Koa.Request & { body: any, files: Files };
105
+ session: Record<string, any>;
106
+ holdFiles: (string | File)[];
107
+ };
108
+
109
+ interface RendererContext {
110
+ handler: HandlerCommon;
111
+ UserContext: UserModel;
112
+ url: HandlerCommon['url'];
113
+ _: HandlerCommon['translate'];
114
+ }
115
+ export interface TextRenderer {
116
+ output: 'html' | 'json' | 'text';
117
+ render: (name: string, args: Record<string, any>, context: RendererContext) => string | Promise<string>;
118
+ }
119
+ export interface BinaryRenderer {
120
+ output: 'binary';
121
+ render: (name: string, args: Record<string, any>, context: RendererContext) => Buffer | Promise<Buffer>;
122
+ }
123
+ export type Renderer = (BinaryRenderer | TextRenderer) & {
124
+ name: string;
125
+ accept: readonly string[];
126
+ priority: number;
127
+ asFallback: boolean;
128
+ };
129
+
130
+ const logger = new Logger('server');
131
+ /** @deprecated */
132
+ export const koa = new Koa<Koa.DefaultState, KoaContext>({
133
+ keys: [Math.random().toString(16).substring(2)],
134
+ });
135
+ export const router = new Router();
136
+ export const httpServer = http.createServer(koa.callback());
137
+ export const wsServer = new WebSocketServer({ server: httpServer });
138
+ koa.on('error', (error) => {
139
+ if (!['ECONNRESET', 'EPIPE', 'ECONNABORTED'].includes(error.code) && !error.message.includes('Parse Error')) {
140
+ logger.error('Koa app-level error', { error });
141
+ }
142
+ });
143
+ wsServer.on('error', (error) => {
144
+ console.log('Websocket server error:', error);
145
+ });
146
+
147
+ export interface UserModel {
148
+ _id: number;
149
+ }
150
+
151
+ export interface HandlerCommon { }
152
+ export class HandlerCommon {
153
+ static [kHandler]: string | boolean = 'HandlerCommon';
154
+ session: Record<string, any>;
155
+ args: Record<string, any>;
156
+ request: HydroRequest;
157
+ response: HydroResponse;
158
+ UiContext: Record<string, any>;
159
+ user: UserModel;
160
+
161
+ constructor(public context: KoaContext, public ctx: CordisContext) {
162
+ this.renderHTML = this.renderHTML.bind(this);
163
+ this.url = this.url.bind(this);
164
+ this.session = context.session;
165
+ this.args = context.HydroContext.args;
166
+ this.request = context.HydroContext.request;
167
+ this.response = context.HydroContext.response;
168
+ this.UiContext = context.HydroContext.UiContext;
169
+ }
170
+
171
+ checkPerm(..._: bigint[]) {
172
+ throw new Error('checkPerm was not implemented');
173
+ }
174
+
175
+ checkPriv(..._: number[]) {
176
+ throw new Error('checkPriv was not implemented');
177
+ }
178
+
179
+ url(name: string, ...kwargsList: Record<string, any>[]) {
180
+ if (name === '#') return '#';
181
+ let res = '#';
182
+ const args: any = Object.create(null);
183
+ const query: any = Object.create(null);
184
+ for (const kwargs of kwargsList) {
185
+ for (const key in kwargs) {
186
+ args[key] = kwargs[key].toString().replace(/\//g, '%2F');
187
+ }
188
+ for (const key in kwargs.query || {}) {
189
+ query[key] = kwargs.query[key].toString();
190
+ }
191
+ }
192
+ try {
193
+ const { anchor } = args;
194
+ res = router.url(name, args, { query }).toString();
195
+ if (anchor) res = `${res}#${anchor}`;
196
+ } catch (e) {
197
+ logger.warn(e.message);
198
+ logger.info('%s %o', name, args);
199
+ if (!e.message.includes('Expected') || !e.message.includes('to match')) logger.info('%s', e.stack);
200
+ }
201
+ return res;
202
+ }
203
+
204
+ translate(str: string) {
205
+ return str;
206
+ }
207
+
208
+ renderHTML(templateName: string, args: Record<string, any>) {
209
+ const renderers = Object.values((this.ctx as any).server.renderers as Record<string, Renderer>)
210
+ .filter((r) => r.accept.includes(templateName) || r.asFallback);
211
+ const topPrio = renderers.sort((a, b) => b.priority - a.priority)[0];
212
+ const engine = topPrio?.render || (() => JSON.stringify(args, serializer(false, this)));
213
+ return engine(templateName, args, {
214
+ handler: this,
215
+ UserContext: this.user,
216
+ url: this.url,
217
+ _: this.translate,
218
+ });
219
+ }
220
+ }
221
+
222
+ export class Handler extends HandlerCommon {
223
+ static [kHandler] = 'Handler';
224
+
225
+ loginMethods: any;
226
+ notUsage = false;
227
+ allowCors = false;
228
+ __param: Record<string, decorators.ParamOption<any>[]>;
229
+
230
+ back(body?: any) {
231
+ this.response.body = body || this.response.body || {};
232
+ this.response.redirect = this.request.headers.referer || '/';
233
+ }
234
+
235
+ binary(data: any, name?: string) {
236
+ this.response.body = data;
237
+ this.response.template = null;
238
+ this.response.type = 'application/octet-stream';
239
+ if (name) this.response.disposition = `attachment; filename="${encodeRFC5987ValueChars(name)}"`;
240
+ }
241
+
242
+ holdFile(name: string | File) {
243
+ this.context.holdFiles.push(name);
244
+ }
245
+
246
+ async init() {
247
+ if (this.request.method === 'post' && this.request.headers.referer && !this.context.cors && !this.allowCors) {
248
+ try {
249
+ const host = new URL(this.request.headers.referer).host;
250
+ if (host !== this.request.host) throw new CsrfTokenError(host);
251
+ } catch (e) {
252
+ throw e instanceof CsrfTokenError ? e : new CsrfTokenError();
253
+ }
254
+ }
255
+ }
256
+
257
+ async onerror(error: HydroError) {
258
+ error.msg ||= () => error.message;
259
+ console.error(`Error on user request: ${error.msg()}\n`, error);
260
+ if (error instanceof UserFacingError && !process.env.DEV) error.stack = '';
261
+ this.response.status = error instanceof UserFacingError ? error.code : 500;
262
+ this.response.template = error instanceof UserFacingError ? 'error.html' : 'bsod.html';
263
+ this.response.body = {
264
+ UserFacingError,
265
+ error: { message: error.msg(), params: error.params, stack: errorMessage(error.stack || '') },
266
+ };
267
+ }
268
+ }
269
+
270
+ export class ConnectionHandler extends HandlerCommon {
271
+ static [kHandler] = 'ConnectionHandler';
272
+
273
+ conn: WebSocket;
274
+ compression: Shorty;
275
+ counter = 0;
276
+
277
+ resetCompression() {
278
+ this.counter = 0;
279
+ this.compression = new Shorty();
280
+ this.conn.send('shorty');
281
+ }
282
+
283
+ send(data: any) {
284
+ let payload = typeof data === 'string' ? data : JSON.stringify(data, serializer(false, this));
285
+ if (this.compression) {
286
+ if (this.counter > 1000) this.resetCompression();
287
+ payload = this.compression.deflate(payload);
288
+ this.counter++;
289
+ }
290
+ this.conn.send(payload);
291
+ }
292
+
293
+ close(code: number, reason: string) {
294
+ this.conn.close(code, reason);
295
+ }
296
+
297
+ onerror(err: HydroError) {
298
+ if (err instanceof UserFacingError) err.stack = this.request.path;
299
+ else console.error('Error on user websocket:', err);
300
+ this.send({
301
+ error: {
302
+ name: err.name,
303
+ params: err.params || [],
304
+ },
305
+ });
306
+ this.close(4000, err.toString());
307
+ }
308
+ }
309
+
310
+ export class NotFoundHandler extends Handler {
311
+ prepare() { throw new NotFoundError(this.request.path); }
312
+ all() { }
313
+ }
314
+
315
+ export interface LayerEntry {
316
+ name: string;
317
+ func: (ctx: any, next: () => Promise<void>) => any;
318
+ }
319
+
320
+ function executeMiddlewareStack(context: any, middlewares: LayerEntry[]): Promise<void> {
321
+ let index = -1;
322
+ context.__timers ||= {};
323
+ function dispatch(i) {
324
+ if (i <= index) return Promise.reject(new Error('next() called multiple times'));
325
+ index = i;
326
+ if (!middlewares[i]) return Promise.resolve();
327
+ const name = middlewares[i].name;
328
+ const fn = middlewares[i].func;
329
+ context.__timers[`${name}.start`] = Date.now();
330
+ try {
331
+ return Promise.resolve(fn(context, dispatch.bind(null, i + 1))).finally(() => {
332
+ context.__timers[`${name}.end`] = Date.now();
333
+ });
334
+ } catch (e) {
335
+ return Promise.reject(e);
336
+ } finally {
337
+ context.__timers[`${name}.end`] = Date.now();
338
+ }
339
+ }
340
+ return dispatch(0);
341
+ }
342
+
343
+ export class WebService extends Service<never> {
344
+ static Config = Schema.object({
345
+ keys: Schema.array(Schema.string()),
346
+ proxy: Schema.boolean(),
347
+ cors: Schema.string(),
348
+ upload: Schema.string(),
349
+ port: Schema.number(),
350
+ host: Schema.string(),
351
+ xff: Schema.string(),
352
+ xhost: Schema.string(),
353
+ enableSSE: Schema.boolean(),
354
+ });
355
+
356
+ private registry: Record<string, any> = Object.create(null);
357
+ private registrationCount = Counter();
358
+ private serverLayers: LayerEntry[] = [];
359
+ private handlerLayers: LayerEntry[] = [];
360
+ private wsLayers: LayerEntry[] = [];
361
+ private captureAllRoutes = Object.create(null);
362
+ private customDefaultContext: CordisContext;
363
+ private activeHandlers: Map<Handler, { start: number, name: string }> = new Map();
364
+
365
+ renderers: Record<string, Renderer> = Object.create(null);
366
+ server = koa;
367
+ router = router;
368
+ HandlerCommon = HandlerCommon;
369
+ private _routeMap: Record<string, string> = Object.create(null);
370
+
371
+ get routeMap(): Record<string, string> {
372
+ return this._routeMap;
373
+ }
374
+
375
+ private rebuildRouteMap() {
376
+ const map: Record<string, string> = Object.create(null);
377
+ for (const layer of this.router.stack) {
378
+ if (layer.name && typeof layer.path === 'string') {
379
+ map[layer.name] = layer.path;
380
+ }
381
+ }
382
+ this._routeMap = map;
383
+ }
384
+
385
+ Handler = Handler;
386
+ ConnectionHandler = ConnectionHandler;
387
+
388
+ constructor(ctx: CordisContext, public config: ReturnType<typeof WebService.Config>) {
389
+ super(ctx, 'server');
390
+ ctx.mixin('server', ['Route', 'Connection', 'withHandlerClass']);
391
+ this.server.keys = this.config.keys;
392
+ this.server.proxy = this.config.proxy;
393
+ const corsAllowHeaders = 'x-requested-with, accept, origin, content-type, upgrade-insecure-requests';
394
+ this.server.use(Compress());
395
+ this.server.use(async (c, next) => {
396
+ if ((c.request.headers.origin || c.request.headers.referer) && this.config.cors) {
397
+ try {
398
+ const host = new URL(c.request.headers.origin || c.request.headers.referer).host;
399
+ if (host !== c.request.headers.host && `,${this.config.cors},`.includes(`,${host},`)) {
400
+ c.set('Access-Control-Allow-Credentials', 'true');
401
+ c.set('Access-Control-Allow-Headers', corsAllowHeaders);
402
+ if (c.request.headers.origin) {
403
+ c.set('Access-Control-Allow-Origin', c.request.headers.origin);
404
+ c.set('Vary', 'Origin');
405
+ } else {
406
+ c.set('Vary', 'Referer');
407
+ }
408
+ c.cors = true;
409
+ }
410
+ } catch (e) {
411
+ // invalid origin header, ignore
412
+ }
413
+ }
414
+ if (c.request.method.toLowerCase() === 'options') {
415
+ c.body = 'ok';
416
+ return null;
417
+ }
418
+ for (const key in this.captureAllRoutes) {
419
+ if (c.path.startsWith(key)) return this.captureAllRoutes[key](c, next);
420
+ }
421
+ return await next();
422
+ });
423
+ if (process.env.DEV) {
424
+ this.server.use(async (c: Koa.Context, next: Function) => {
425
+ const startTime = Date.now();
426
+ try {
427
+ await next();
428
+ } finally {
429
+ const endTime = Date.now();
430
+ if (!c.nolog && !c.response.headers.nolog) {
431
+ logger.debug(`${c.request.method} /${c.domainId || 'system'}${c.request.path} \
432
+ ${c.response.status} ${endTime - startTime}ms ${c.response.length}`);
433
+ }
434
+ }
435
+ });
436
+ }
437
+ if (this.config.upload) {
438
+ const uploadDir = join(tmpdir(), 'hydro', 'upload', process.env.NODE_APP_INSTANCE || '0');
439
+ fs.ensureDirSync(uploadDir);
440
+ logger.debug('Using upload dir: %s', uploadDir);
441
+ this.server.use(Body({
442
+ multipart: true,
443
+ jsonLimit: '8mb',
444
+ formLimit: '8mb',
445
+ formidable: {
446
+ uploadDir,
447
+ allowEmptyFiles: true,
448
+ minFileSize: 0,
449
+ maxFileSize: parseMemoryMB(this.config.upload) * 1024 * 1024,
450
+ keepExtensions: true,
451
+ },
452
+ }));
453
+ this.server.use(async (c, next) => {
454
+ c.holdFiles = [];
455
+ try {
456
+ await next();
457
+ } finally {
458
+ if (Object.keys(c.request.files || {}).length) {
459
+ for (const k in c.request.files) {
460
+ if (c.holdFiles.includes(k)) continue;
461
+ const files = Array.isArray(c.request.files[k]) ? c.request.files[k] : [c.request.files[k]];
462
+ for (const f of files) if (!c.holdFiles.includes(f as any)) fs.rmSync(f.filepath);
463
+ }
464
+ }
465
+ }
466
+ });
467
+ this.ctx.effect(() => () => {
468
+ fs.emptyDirSync(uploadDir);
469
+ });
470
+ // if killed by ctrl-c, on('dispose') will not be called
471
+ process.on('exit', () => {
472
+ fs.emptyDirSync(uploadDir);
473
+ });
474
+ } else {
475
+ this.server.use(Body({
476
+ multipart: true,
477
+ jsonLimit: '8mb',
478
+ formLimit: '8mb',
479
+ }));
480
+ }
481
+ this.router.use((c, next) => executeMiddlewareStack(c, [
482
+ ...this.handlerLayers,
483
+ { name: 'logic', func: next },
484
+ ]).catch(console.error));
485
+ this.server.use((c) => executeMiddlewareStack(c, [
486
+ ...this.serverLayers,
487
+ { name: 'routes', func: router.routes() },
488
+ { name: 'methods', func: router.allowedMethods() },
489
+ ...this.handlerLayers,
490
+ {
491
+ name: '404',
492
+ func: (t) => this.handleHttp(t, NotFoundHandler, () => true, this.customDefaultContext || this.ctx),
493
+ },
494
+ ]));
495
+ this.addLayer('base', base(logger, this.config.xff, this.config.xhost));
496
+ wsServer.on('connection', async (socket, request) => {
497
+ socket.on('error', (err) => {
498
+ logger.warn('Websocket Error: %s', err.message);
499
+ try {
500
+ socket.close(1003, 'Websocket Error');
501
+ } catch (e) { }
502
+ });
503
+ socket.pause();
504
+ const KoaContext: any = koa.createContext(request, {} as any);
505
+ await executeMiddlewareStack(KoaContext, this.wsLayers);
506
+ for (const manager of router.wsStack) {
507
+ if (manager.accept(socket, request, KoaContext)) return;
508
+ }
509
+ socket.close();
510
+ });
511
+ }
512
+
513
+ public statistics() {
514
+ const count = Counter();
515
+ for (const [, t] of this.activeHandlers.entries()) {
516
+ count[t.name]++;
517
+ }
518
+ return count;
519
+ }
520
+
521
+ async listen() {
522
+ this.ctx.effect(() => () => {
523
+ httpServer.close();
524
+ wsServer.close();
525
+ });
526
+ await new Promise((r) => {
527
+ httpServer.listen(this.config.port, this.config.host || '127.0.0.1', () => {
528
+ logger.success('Server listening at: %c', `${this.config.host || '127.0.0.1'}:${this.config.port}`);
529
+ r(true);
530
+ });
531
+ });
532
+ }
533
+
534
+ private async handleHttp(ctx: KoaContext, HandlerClass, checker, savedContext: CordisContext) {
535
+ const { args } = ctx.HydroContext;
536
+ Object.assign(args, ctx.params);
537
+ await using sub = await forkContextWithScope(savedContext);
538
+ const h = new HandlerClass(ctx, sub.ctx);
539
+ ctx.handler = h;
540
+ const method = ctx.method.toLowerCase();
541
+ const name = ((Object.hasOwn(HandlerClass, kHandler) && typeof HandlerClass[kHandler] === 'string')
542
+ ? HandlerClass[kHandler] : HandlerClass.name).replace(/Handler$/, '');
543
+ this.activeHandlers.set(h, { start: Date.now(), name });
544
+ try {
545
+ const operation = (method === 'post' && ctx.request.body?.operation)
546
+ // eslint-disable-next-line regexp/no-unused-capturing-group
547
+ ? `_${ctx.request.body.operation}`.replace(/_([a-z])/g, (s) => s[1].toUpperCase())
548
+ : '';
549
+
550
+ // FIXME: should pass type check
551
+ await (this.ctx.parallel as any)('handler/create', h, 'http');
552
+ await (this.ctx.parallel as any)('handler/create/http', h);
553
+
554
+ if (checker) checker.call(h);
555
+ if (typeof h.all !== 'function') {
556
+ if (method === 'post') {
557
+ if (operation) {
558
+ if (typeof h[`post${operation}`] !== 'function') {
559
+ throw new InvalidOperationError(operation);
560
+ }
561
+ } else if (typeof h.post !== 'function') {
562
+ throw new MethodNotAllowedError(method);
563
+ }
564
+ } else if (typeof h[method] !== 'function') {
565
+ throw new MethodNotAllowedError(method);
566
+ }
567
+ }
568
+
569
+ const steps = [
570
+ 'log/__init', 'init', 'handler/init',
571
+ `handler/before-prepare/${name}#${method}`, `handler/before-prepare/${name}`, 'handler/before-prepare',
572
+ 'log/__prepare', '__prepare', '_prepare', 'prepare', 'log/__prepareDone',
573
+ `handler/before/${name}#${method}`, `handler/before/${name}`, 'handler/before',
574
+ 'log/__method', 'all', method, 'log/__methodDone',
575
+ ...operation ? [
576
+ `handler/before-operation/${name}`, 'handler/before-operation',
577
+ `post${operation}`, 'log/__operationDone',
578
+ ] : [], 'after',
579
+ `handler/after/${name}#${method}`, `handler/after/${name}`, 'handler/after',
580
+ 'cleanup',
581
+ `handler/finish/${name}#${method}`, `handler/finish/${name}`, 'handler/finish',
582
+ 'log/__finish',
583
+ ];
584
+
585
+ let current = 0;
586
+ while (current < steps.length) {
587
+ const step = steps[current];
588
+ let control;
589
+ if (step.startsWith('log/')) h.args[step.slice(4)] = Date.now();
590
+ // @ts-ignore
591
+ else if (step.startsWith('handler/')) control = await this.ctx.serial(step, h); // eslint-disable-line no-await-in-loop
592
+ // eslint-disable-next-line no-await-in-loop
593
+ else if (typeof h[step] === 'function') control = await h[step](args);
594
+ if (control) {
595
+ const index = steps.findIndex((i) => control === i);
596
+ if (index === -1) throw new Error(`Invalid control: ${control} (after step ${step})`);
597
+ if (index <= current) {
598
+ logger.warn('Returning to previous step is not recommended:', step, '->', control);
599
+ }
600
+ current = index;
601
+ } else current++;
602
+ }
603
+ } catch (e) {
604
+ try {
605
+ // FIXME: should pass type check
606
+ await (this.ctx.serial as any)(`handler/error/${name}`, h, e);
607
+ await (this.ctx.serial as any)('handler/error', h, e);
608
+ await h.onerror(e);
609
+ } catch (err) {
610
+ logger.error(err);
611
+ h.response.status = 500;
612
+ h.response.type = 'text/plain';
613
+ h.response.body = `${err.message}\n${err.stack}`;
614
+ }
615
+ } finally {
616
+ this.activeHandlers.delete(h);
617
+ }
618
+ }
619
+
620
+ private async handleWS(ctx: KoaContext, HandlerClass, checker, conn?, layer?, savedContext?: CordisContext) {
621
+ const { args } = ctx.HydroContext;
622
+ const sub = await forkContextWithScope(savedContext);
623
+ const h = new HandlerClass(ctx, sub.ctx);
624
+ let stream: PassThrough;
625
+ if (!conn) {
626
+ // By HTTP
627
+ stream = new PassThrough();
628
+ ctx.request.socket.setTimeout(0);
629
+ ctx.req.socket.setNoDelay(true);
630
+ ctx.req.socket.setKeepAlive(true);
631
+ ctx.set({
632
+ 'X-Accel-Buffering': 'no',
633
+ 'Content-Type': 'text/event-stream',
634
+ 'Cache-Control': 'no-cache',
635
+ Connection: 'keep-alive',
636
+ });
637
+ ctx.HydroContext.request.websocket = true;
638
+ ctx.compress = false;
639
+ conn = {
640
+ close() {
641
+ stream.end();
642
+ },
643
+ send(data: any) {
644
+ stream.write(`${args.sse ? 'data: ' : ''}${data}\n${args.sse ? '\n' : ''}`);
645
+ },
646
+ };
647
+ }
648
+ ctx.handler = h;
649
+ h.conn = conn;
650
+ let closed = false;
651
+ this.activeHandlers.set(h, { start: Date.now(), name: HandlerClass.name });
652
+
653
+ const clean = async (err?: Error) => {
654
+ if (closed) return;
655
+ closed = true;
656
+ try {
657
+ try {
658
+ if (err) await h.onerror(err);
659
+ // FIXME: should pass type check
660
+ else (this.ctx.emit as any)('connection/close', h);
661
+ } finally {
662
+ h.active = false;
663
+ if (layer) layer.clients.delete(conn);
664
+ if (err && !layer) ctx.status = 500;
665
+ await h.cleanup?.(args);
666
+ }
667
+ } finally {
668
+ await sub.dispose();
669
+ this.activeHandlers.delete(h);
670
+ }
671
+ };
672
+
673
+ try {
674
+ // FIXME: should pass type check
675
+ await (this.ctx.parallel as any)('handler/create', h, 'ws');
676
+ await (this.ctx.parallel as any)('handler/create/ws', h);
677
+ checker.call(h);
678
+ if (args.shorty) h.resetCompression();
679
+ if (h._prepare) await h._prepare(args);
680
+ if (h.prepare) await h.prepare(args);
681
+ for (const { name, target } of h.__subscribe || []) sub.ctx.on(name, target.bind(h));
682
+ if (layer) {
683
+ let lastHeartbeat = Date.now();
684
+ sub.ctx.interval(() => {
685
+ if (Date.now() - lastHeartbeat > 80000) {
686
+ clean();
687
+ conn.terminate();
688
+ }
689
+ if (Date.now() - lastHeartbeat > 30000) conn.send('ping');
690
+ }, 40000);
691
+ conn.on('pong', () => {
692
+ lastHeartbeat = Date.now();
693
+ });
694
+ conn.onmessage = async (e) => {
695
+ lastHeartbeat = Date.now();
696
+ if (e.data === 'pong') return;
697
+ if (e.data === 'ping') {
698
+ conn.send('pong');
699
+ return;
700
+ }
701
+ let payload;
702
+ try {
703
+ payload = JSON.parse(e.data.toString());
704
+ } catch (err) {
705
+ await clean(err);
706
+ }
707
+ try {
708
+ await h.message?.(payload);
709
+ } catch (err) {
710
+ logger.error(e);
711
+ }
712
+ };
713
+ } else ctx.body = stream;
714
+ // FIXME: should pass type check
715
+ await (this.ctx.parallel as any)('connection/active', h as any);
716
+ h.active = true;
717
+ if (layer) {
718
+ if (conn.readyState === conn.OPEN) {
719
+ conn.on('close', () => clean());
720
+ conn.on('error', (err) => clean(err));
721
+ conn.resume();
722
+ } else clean();
723
+ } else {
724
+ stream.on('close', () => clean());
725
+ stream.on('error', (err) => clean(err));
726
+ }
727
+ } catch (e) {
728
+ // error during initialization (prepare, hooks)
729
+ await clean(e);
730
+ }
731
+ }
732
+
733
+ private register(type: 'route' | 'conn', routeName: string, path: string, HandlerClass: any, ...permPrivChecker) {
734
+ if (!HandlerClass?.[kHandler] || !isClass(HandlerClass)) throw new Error('Invalid registration.');
735
+ const name = ((Object.hasOwn(HandlerClass, kHandler) && typeof HandlerClass[kHandler] === 'string')
736
+ ? HandlerClass[kHandler] : HandlerClass.name).replace(/Handler$/, '');
737
+ if (this.registrationCount[name] && this.registry[name] !== HandlerClass) {
738
+ logger.warn('Route with name %s already exists.', name);
739
+ }
740
+ this.registry[name] = HandlerClass;
741
+ this.registrationCount[name]++;
742
+
743
+ const Checker = (args) => {
744
+ let perm: bigint;
745
+ let priv: number;
746
+ let checker = () => { };
747
+ for (const item of args) {
748
+ if (typeof item === 'object') {
749
+ if (typeof item.call !== 'undefined') {
750
+ checker = item;
751
+ } else if (typeof item[0] === 'number') {
752
+ priv = item;
753
+ } else if (typeof item[0] === 'bigint') {
754
+ perm = item;
755
+ }
756
+ } else if (typeof item === 'number') {
757
+ priv = item;
758
+ } else if (typeof item === 'bigint') {
759
+ perm = item;
760
+ }
761
+ }
762
+ return function check(this: Handler) {
763
+ checker();
764
+ if (perm) this.checkPerm(perm);
765
+ if (priv) this.checkPriv(priv);
766
+ };
767
+ };
768
+
769
+ // We hope to use parent context for handler (the context that calls register)
770
+ // So that handler can use services injected before calling register
771
+ const savedContext = Object.hasOwn(this.ctx, Symbol.for('cordis.shadow'))
772
+ ? Object.getPrototypeOf(this.ctx)
773
+ : this.ctx;
774
+ const checker = Checker(permPrivChecker);
775
+ if (type === 'route') {
776
+ router.all(routeName, path, (ctx) => this.handleHttp(ctx as any, HandlerClass, checker, savedContext));
777
+ } else {
778
+ const layer = router.ws(path, async (conn, _req, ctx) => {
779
+ await this.handleWS(ctx as any, HandlerClass, checker, conn, layer, savedContext);
780
+ });
781
+ if (this.config.enableSSE) {
782
+ router.get(routeName, path, async (ctx) => {
783
+ Object.assign(ctx.HydroContext.args, ctx.params);
784
+ await this.handleWS(ctx as any, HandlerClass, checker, null, null, savedContext);
785
+ });
786
+ }
787
+ }
788
+ const dispose = router.disposeLastOp;
789
+ this.rebuildRouteMap();
790
+ // @ts-ignore
791
+ this.ctx.parallel(`handler/register/${name}`, HandlerClass);
792
+ this.ctx.effect(() => () => {
793
+ this.registrationCount[name]--;
794
+ if (!this.registrationCount[name]) delete this.registry[name];
795
+ dispose();
796
+ this.rebuildRouteMap();
797
+ });
798
+ }
799
+
800
+ public setDefaultContext(ctx: CordisContext) {
801
+ try {
802
+ if (!ctx.server) throw new Error();
803
+ } catch (e) {
804
+ throw new Error('Must provide a valid context with server.');
805
+ }
806
+ this.ctx.effect(async () => {
807
+ if (this.customDefaultContext) logger.warn('Default context already set.');
808
+ this.customDefaultContext = ctx;
809
+ return () => {
810
+ this.customDefaultContext = null;
811
+ };
812
+ });
813
+ }
814
+
815
+ public withHandlerClass<T extends string>(
816
+ name: T, callback: (HandlerClass: T extends `${string}ConnectionHandler` ? typeof ConnectionHandler : typeof Handler) => any,
817
+ ) {
818
+ name = name.replace(/Handler$/, '') as any;
819
+ if (this.registry[name]) callback(this.registry[name]);
820
+ // FIXME: should pass type check
821
+ this.ctx.on(`handler/register/${name}`, callback as any);
822
+ }
823
+
824
+ // eslint-disable-next-line ts/naming-convention
825
+ public Route(name: string, path: string, RouteHandler: typeof Handler, ...permPrivChecker) {
826
+ return this.register('route', name, path, RouteHandler, ...permPrivChecker);
827
+ }
828
+
829
+ // eslint-disable-next-line ts/naming-convention
830
+ public Connection(name: string, path: string, RouteHandler: typeof ConnectionHandler, ...permPrivChecker) {
831
+ return this.register('conn', name, path, RouteHandler, ...permPrivChecker);
832
+ }
833
+
834
+ private registerLayer(name: 'serverLayers' | 'handlerLayers' | 'wsLayers', layer: LayerEntry) {
835
+ this.ctx.effect(() => {
836
+ this[name].push(layer);
837
+ return () => {
838
+ this[name] = this[name].filter((i) => i !== layer);
839
+ };
840
+ });
841
+ }
842
+
843
+ public addServerLayer(name: LayerEntry['name'], func: LayerEntry['func']) {
844
+ return this.registerLayer('serverLayers', { name, func });
845
+ }
846
+
847
+ public addHandlerLayer(name: LayerEntry['name'], func: LayerEntry['func']) {
848
+ return this.registerLayer('handlerLayers', { name, func });
849
+ }
850
+
851
+ public addWSLayer(name: LayerEntry['name'], func: LayerEntry['func']) {
852
+ return this.registerLayer('wsLayers', { name, func });
853
+ }
854
+
855
+ public addLayer(name: LayerEntry['name'], layer: LayerEntry['func']) {
856
+ this.addHandlerLayer(name, layer);
857
+ this.addWSLayer(name, layer);
858
+ }
859
+
860
+ public addCaptureRoute(prefix: string, cb: any) {
861
+ this.captureAllRoutes[prefix] = cb;
862
+ }
863
+
864
+ private _applyMixin(Target: any, MixinClass: Partial<any> | ((t: Partial<any>) => Partial<any>)) {
865
+ if (!('prototype' in Target)) throw new Error('Target must be a class.');
866
+ if (typeof MixinClass === 'function') MixinClass = MixinClass(Target.prototype);
867
+ this.ctx.effect(() => {
868
+ let oldValue = null;
869
+ for (const val of Object.getOwnPropertyNames(MixinClass)) {
870
+ if (Target.prototype[val]) {
871
+ logger.warn(`${Target.name}.prototype[${val}] already exists.`);
872
+ oldValue = Target.prototype[val];
873
+ }
874
+ Target.prototype[val] = MixinClass[val];
875
+ }
876
+ return () => {
877
+ for (const val of Object.getOwnPropertyNames(MixinClass)) {
878
+ if (Target.prototype[val] !== MixinClass[val]) {
879
+ logger.warn(`Failed to unload mixin ${Target.name}.prototype[${val}]: not the same as the original value.`);
880
+ } else {
881
+ delete Target.prototype[val];
882
+ if (oldValue) Target.prototype[val] = oldValue;
883
+ }
884
+ }
885
+ };
886
+ });
887
+ }
888
+
889
+ public applyMixin<T extends keyof KnownHandlers>(name: T, MixinClass: any) {
890
+ this.withHandlerClass(name, (HandlerClass) => {
891
+ this._applyMixin(HandlerClass, MixinClass);
892
+ });
893
+ }
894
+
895
+ public handlerMixin(MixinClass: Partial<HandlerCommon> | ((s: HandlerCommon) => Partial<HandlerCommon>)) {
896
+ return this._applyMixin(HandlerCommon, MixinClass);
897
+ }
898
+
899
+ public httpHandlerMixin(MixinClass: Partial<Handler> | ((s: Handler) => Partial<Handler>)) {
900
+ return this._applyMixin(Handler, MixinClass);
901
+ }
902
+
903
+ public wsHandlerMixin(MixinClass: Partial<ConnectionHandler> | ((s: ConnectionHandler) => Partial<ConnectionHandler>)) {
904
+ return this._applyMixin(ConnectionHandler, MixinClass);
905
+ }
906
+
907
+ public registerRenderer(name: string, func: Renderer) {
908
+ if (this.renderers[name]) logger.warn('Renderer %s already exists.', name);
909
+ this.ctx.effect(() => {
910
+ this.renderers[name] = func;
911
+ return () => {
912
+ delete this.renderers[name];
913
+ };
914
+ });
915
+ }
916
+ }
917
+
918
+ declare module 'cordis' {
919
+ interface Context {
920
+ server: WebService;
921
+ Route: WebService['Route'];
922
+ Connection: WebService['Connection'];
923
+ withHandlerClass: WebService['withHandlerClass'];
924
+ }
925
+ }