framework: add event stream support

This commit is contained in:
undefined committed 2025-02-19 03:50:58 +08:00
1 parent fc0fd0fe29
commit 6f744e9f97
3 files changed
+42 -8

No files matched your search

+2 -2
View File
@@ -27,8 +27,8 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => {
template: null, template: null,
redirect: null, redirect: null,
attachment: (name, streamOrBuffer) => { attachment: (name, streamOrBuffer) => {
ctx.attachment(name); if (name) ctx.attachment(name);
if (streamOrBuffer instanceof Buffer) { if (streamOrBuffer instanceof Buffer || streamOrBuffer instanceof PassThrough) {
response.body = null; response.body = null;
ctx.body = streamOrBuffer; ctx.body = streamOrBuffer;
} else { } else {
+1 -1
View File
@@ -10,7 +10,7 @@
"@hydrooj/utils": "workspace:^", "@hydrooj/utils": "workspace:^",
"emoji-regex": "^10.4.0", "emoji-regex": "^10.4.0",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"koa": "^2.15.3", "koa": "^2.15.4",
"koa-body": "^6.0.1", "koa-body": "^6.0.1",
"koa-compress": "^5.1.1", "koa-compress": "^5.1.1",
"koa-router": "^13.0.1", "koa-router": "^13.0.1",
+39 -5
View File
@@ -1,6 +1,7 @@
import http from 'http'; import http from 'http';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { PassThrough } from 'stream';
import { Context, Service } from 'cordis'; import { Context, Service } from 'cordis';
import type { Files } from 'formidable'; import type { Files } from 'formidable';
import fs from 'fs-extra'; import fs from 'fs-extra';
@@ -345,7 +346,7 @@ export class WebService extends Service {
ConnectionHandler = ConnectionHandler; ConnectionHandler = ConnectionHandler;
constructor(ctx: Context, public config: WebServiceConfig) { constructor(ctx: Context, public config: WebServiceConfig) {
super(ctx, 'server', true); super(ctx, 'server');
ctx.mixin('server', ['Route', 'Connection', 'withHandlerClass']); ctx.mixin('server', ['Route', 'Connection', 'withHandlerClass']);
this.server.keys = this.config.keys; this.server.keys = this.config.keys;
this.server.proxy = this.config.proxy; this.server.proxy = this.config.proxy;
@@ -353,6 +354,7 @@ export class WebService extends Service {
this.server.use(Compress()); this.server.use(Compress());
this.server.use(async (c, next) => { this.server.use(async (c, next) => {
if (c.request.headers.origin && this.config.cors) { if (c.request.headers.origin && this.config.cors) {
try {
const host = new URL(c.request.headers.origin).host; const host = new URL(c.request.headers.origin).host;
if (host !== c.request.headers.host && `,${this.config.cors},`.includes(`,${host},`)) { if (host !== c.request.headers.host && `,${this.config.cors},`.includes(`,${host},`)) {
c.set('Access-Control-Allow-Credentials', 'true'); c.set('Access-Control-Allow-Credentials', 'true');
@@ -361,6 +363,9 @@ export class WebService extends Service {
c.set('Vary', 'Origin'); c.set('Vary', 'Origin');
c.cors = true; c.cors = true;
} }
} catch (e) {
// invalid origin header, ignore
}
} }
if (c.request.method.toLowerCase() === 'options') { if (c.request.method.toLowerCase() === 'options') {
c.body = 'ok'; c.body = 'ok';
@@ -378,7 +383,7 @@ export class WebService extends Service {
await next(); await next();
} finally { } finally {
const endTime = Date.now(); const endTime = Date.now();
if (!(c.nolog || c.response.headers.nolog)) { if (!c.nolog && !c.response.headers.nolog) {
logger.debug(`${c.request.method} /${c.domainId || 'system'}${c.request.path} \ logger.debug(`${c.request.method} /${c.domainId || 'system'}${c.request.path} \
${c.response.status} ${endTime - startTime}ms ${c.response.length}`); ${c.response.status} ${endTime - startTime}ms ${c.response.length}`);
} }
@@ -547,10 +552,33 @@ ${c.response.status} ${endTime - startTime}ms ${c.response.length}`);
} }
} }
private async handleWS(ctx: KoaContext, HandlerClass, checker, conn, layer) { private async handleWS(ctx: KoaContext, HandlerClass, checker, conn, layer?) {
const { args } = ctx.HydroContext; const { args } = ctx.HydroContext;
const h = new HandlerClass(ctx, this.ctx); const h = new HandlerClass(ctx, this.ctx);
await this.ctx.parallel('connection/create', h); await this.ctx.parallel('connection/create', h);
const stream = new PassThrough();
if (!conn) {
// By HTTP
ctx.request.socket.setTimeout(0);
ctx.req.socket.setNoDelay(true);
ctx.req.socket.setKeepAlive(true);
ctx.set({
'X-Accel-Buffering': 'no',
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
ctx.HydroContext.request.websocket = true;
ctx.compress = false;
conn = {
close() {
stream.end();
},
send(data: any) {
stream.write(`${args.sse ? 'data: ' : ''}${data}\n${args.sse ? '\n' : ''}`);
},
};
}
ctx.handler = h; ctx.handler = h;
h.conn = conn; h.conn = conn;
const disposables = []; const disposables = [];
@@ -562,18 +590,19 @@ ${c.response.status} ${endTime - startTime}ms ${c.response.length}`);
if (h.prepare) await h.prepare(args); if (h.prepare) await h.prepare(args);
// eslint-disable-next-line @typescript-eslint/no-shadow // eslint-disable-next-line @typescript-eslint/no-shadow
for (const { name, target } of h.__subscribe || []) disposables.push(this.ctx.on(name, target.bind(h))); for (const { name, target } of h.__subscribe || []) disposables.push(this.ctx.on(name, target.bind(h)));
let lastHeartbeat = Date.now();
let closed = false; let closed = false;
let interval: NodeJS.Timeout; let interval: NodeJS.Timeout;
const clean = () => { const clean = () => {
if (closed) return; if (closed) return;
closed = true; closed = true;
this.ctx.emit('connection/close', h); this.ctx.emit('connection/close', h);
layer.clients.delete(conn); if (layer) layer.clients.delete(conn);
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
for (const d of disposables) d(); for (const d of disposables) d();
h.cleanup?.(args); h.cleanup?.(args);
}; };
if (layer) {
let lastHeartbeat = Date.now();
interval = setInterval(() => { interval = setInterval(() => {
if (Date.now() - lastHeartbeat > 80000) { if (Date.now() - lastHeartbeat > 80000) {
clean(); clean();
@@ -603,13 +632,17 @@ ${c.response.status} ${endTime - startTime}ms ${c.response.length}`);
logger.error(e); logger.error(e);
} }
}; };
} else ctx.body = stream;
await this.ctx.parallel('connection/active', h); await this.ctx.parallel('connection/active', h);
if (layer) {
if (conn.readyState === conn.OPEN) { if (conn.readyState === conn.OPEN) {
conn.on('close', clean); conn.on('close', clean);
conn.resume(); conn.resume();
} else clean(); } else clean();
} else stream.on('close', clean);
} catch (e) { } catch (e) {
await h.onerror(e); await h.onerror(e);
if (!layer) ctx.status = 500;
} }
} }
@@ -628,6 +661,7 @@ ${c.response.status} ${endTime - startTime}ms ${c.response.length}`);
const layer = router.ws(path, async (conn, _req, ctx) => { const layer = router.ws(path, async (conn, _req, ctx) => {
await this.handleWS(ctx as any, HandlerClass, checker, conn, layer); await this.handleWS(ctx as any, HandlerClass, checker, conn, layer);
}); });
router.get(path, (ctx) => this.handleWS(ctx as any, HandlerClass, checker, null, null));
} }
const dispose = router.disposeLastOp; const dispose = router.disposeLastOp;
// @ts-ignore // @ts-ignore