perf: 300명 실시간 부하 측정 도구를 추가
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import type { PhaseMetrics } from './metrics.js';
|
||||
|
||||
const forbiddenKeys = new Set([
|
||||
'at',
|
||||
'lastTurnTime',
|
||||
'revision',
|
||||
'generalId',
|
||||
'cityId',
|
||||
'nationId',
|
||||
'entityId',
|
||||
'mailboxId',
|
||||
'messageId',
|
||||
'senderId',
|
||||
]);
|
||||
|
||||
export const containsForbiddenPublicField = (value: unknown): boolean => {
|
||||
if (Array.isArray(value)) return value.some(containsForbiddenPublicField);
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
return Object.entries(value).some(([key, item]) => forbiddenKeys.has(key) || containsForbiddenPublicField(item));
|
||||
};
|
||||
|
||||
export interface ParsedSseEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export class SseParser {
|
||||
private buffer = '';
|
||||
private eventName = 'message';
|
||||
private data: string[] = [];
|
||||
|
||||
constructor(private readonly onEvent: (event: ParsedSseEvent) => void) {}
|
||||
|
||||
push(chunk: string): void {
|
||||
this.buffer += chunk;
|
||||
let newline = this.buffer.indexOf('\n');
|
||||
while (newline >= 0) {
|
||||
const rawLine = this.buffer.slice(0, newline);
|
||||
this.buffer = this.buffer.slice(newline + 1);
|
||||
this.consumeLine(rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine);
|
||||
newline = this.buffer.indexOf('\n');
|
||||
}
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
if (this.buffer.length > 0) this.consumeLine(this.buffer.endsWith('\r') ? this.buffer.slice(0, -1) : this.buffer);
|
||||
this.buffer = '';
|
||||
this.dispatch();
|
||||
}
|
||||
|
||||
private consumeLine(line: string): void {
|
||||
if (line === '') {
|
||||
this.dispatch();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(':')) return;
|
||||
const separator = line.indexOf(':');
|
||||
const field = separator < 0 ? line : line.slice(0, separator);
|
||||
let value = separator < 0 ? '' : line.slice(separator + 1);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
if (field === 'event') this.eventName = value;
|
||||
if (field === 'data') this.data.push(value);
|
||||
}
|
||||
|
||||
private dispatch(): void {
|
||||
if (this.data.length > 0) this.onEvent({ event: this.eventName, data: this.data.join('\n') });
|
||||
this.eventName = 'message';
|
||||
this.data = [];
|
||||
}
|
||||
}
|
||||
|
||||
const wait = (milliseconds: number, signal: AbortSignal): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (signal.aborted) return resolve();
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', done);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(done, milliseconds);
|
||||
signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
|
||||
export const runSseConnection = async (options: {
|
||||
url: string;
|
||||
token: string;
|
||||
signal: AbortSignal;
|
||||
metrics: PhaseMetrics;
|
||||
onActiveChange: (delta: number) => void;
|
||||
reconnectDelayMs?: number;
|
||||
}): Promise<void> => {
|
||||
let priorAttempt = false;
|
||||
while (!options.signal.aborted) {
|
||||
if (priorAttempt) options.metrics.sseReconnects += 1;
|
||||
priorAttempt = true;
|
||||
options.metrics.sseAttempts += 1;
|
||||
let active = false;
|
||||
try {
|
||||
const response = await fetch(options.url, {
|
||||
headers: { accept: 'text/event-stream', authorization: `Bearer ${options.token}` },
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
options.metrics.sseFailures += 1;
|
||||
await response.body?.cancel();
|
||||
} else {
|
||||
options.metrics.sseOpened += 1;
|
||||
active = true;
|
||||
options.onActiveChange(1);
|
||||
const parser = new SseParser(({ event, data }) => {
|
||||
options.metrics.recordSseEvent(event);
|
||||
try {
|
||||
if (containsForbiddenPublicField(JSON.parse(data))) options.metrics.ssePrivacyViolations += 1;
|
||||
} catch {
|
||||
options.metrics.sseFailures += 1;
|
||||
}
|
||||
});
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of response.body) parser.push(decoder.decode(chunk, { stream: true }));
|
||||
parser.push(decoder.decode());
|
||||
parser.finish();
|
||||
if (!options.signal.aborted) options.metrics.sseFailures += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!options.signal.aborted) options.metrics.sseFailures += 1;
|
||||
} finally {
|
||||
if (active) {
|
||||
options.metrics.sseClosed += 1;
|
||||
options.onActiveChange(-1);
|
||||
}
|
||||
}
|
||||
if (!options.signal.aborted) await wait(options.reconnectDelayMs ?? 1000, options.signal);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user