사용자:Hsl0/연구소/숫자야구 live/실시간.js
보이기
참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.
- 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
- 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
- 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
var _a, _b, _c, _d, _e, _f;
const TIMEOUT = Symbol('timeout_id');
const STOPPED = Symbol('is_stopped');
const RUNNING = Symbol('is_running');
const THROTTLE = Symbol('throttle_timeout_id');
const DOWN_START = Symbol('process_start_time');
const REQUEST = Symbol('request_event_handler');
const FETCH = Symbol('fetch_event_handler');
const CLIENTS = Symbol('clients_list');
function parseJSON(text, reviver) {
try {
return JSON.parse(text, reviver);
}
catch (error) {
return text;
}
}
/** 실시간 클라이언트 */
export class LiveClient extends EventTarget {
/**
* LiveClient 객체 생성자
* @param title 편집 대상 문서
* @param options 추가 설정 객체
*/
constructor(title, options = {}) {
var _g;
if (!title)
throw new TypeError('문서를 지정해 주세요');
super();
/** 시작 리비전 */
this.now = null;
/** 자동 요청 실행 여부 */
this[_a] = false;
/** 정지 예약 여부, 다음 이벤트 무시 */
this[_b] = false;
/** 쓰로틀 해제 코드 */
this[_c] = null;
/** 필수 대기 해제 코드 */
this[_d] = null;
this.target = title;
this.api = options.api || this.constructor.api;
this.interval = {
...this.constructor.interval,
...options.interval,
};
this.loadParams = {
...this.constructor.loadParams,
titles: this.target,
...options.loadParams,
};
this.postParams = {
...this.constructor.postParams,
title: this.target,
...options.postParams,
};
this.loadAjax = {
...this.constructor.loadAjax,
...options.loadAjax,
};
this.postAjax = {
...this.constructor.postAjax,
...options.postAjax,
};
this.now = options.from || null;
this.stopOnError = (_g = options.stopOnError) !== null && _g !== void 0 ? _g : true;
}
/**
* 1회 불러오기
* @param from 시작 리비전
* @param options 추가 파라미터 및 AJAX 설정
* @returns 원본 응답, 리비전, 시작 리비전, 추가 요청 가능 여부를 묶은 객체
*/
loadOnce(from, { params, ajax } = {}) {
from !== null && from !== void 0 ? from : (from = this.now);
// 시작 리비전
return this.api
.get({ ...from, ...this.loadParams, ...params }, { ...this.loadAjax, ...ajax })
.then((response, xhr) => {
var _g;
if (response.error) {
throw {
type: response.error.code,
info: response.error.info,
xhr: xhr,
target: from || null,
response: response,
exception: null,
};
}
else if (response.query) {
const revisions = (_g = response.query.pages[0]) === null || _g === void 0 ? void 0 : _g.revisions;
const result = {
response,
revisions,
target: from,
more: Boolean(response.continue),
};
return result;
}
else
throw new TypeError('API 결과를 받지 못했습니다');
}, (type, info, response, xhr) => {
throw {
type: type === 'http' &&
info.textStatus !== 'error'
? info.textStatus
: type,
info: response === null || response === void 0 ? void 0 : response.error.info,
xhr: xhr || info,
target: from,
response: response || null,
exception: xhr ? null : info.exception,
};
});
}
/**
* 끝까지 불러오기, 마지막까지 불러오면 종료
* @param from 시작 리비전
* @param options 추가 파라미터 및 AJAX 설정
*/
async *loadAll(from = this.now, { params, ajax } = {}) {
let result;
do {
result = await this.loadOnce(from, { params, ajax });
yield result;
} while (result.more);
}
/**
* 무한 반복 불러오기 코루틴
* @param from 시작 리비전
* @param globalOptions 추가 파라미터 및 AJAX 설정
*/
async *load(from = this.now, globalOptions = {}) {
let options = globalOptions;
while (true) {
const result = await this.loadOnce(from, options);
if (result === null || result === void 0 ? void 0 : result.revisions) {
// 이번 요청에서 데이터가 갱신되었을 때
if (result.more) {
// 더 불러올 데이터가 있으면
from = result.response.continue;
}
else {
// 더 불러올 데이터가 없으면
const last = result.revisions[result.revisions.length - 1];
from = {
rvstartid: last.revid + 1,
rvstart: last.timestamp,
};
}
}
options = { ...globalOptions, ...(yield result) };
}
}
/**
* 데이터 전송
* @param content 내용
* @param options 추가 파라미터 및 AJAX 설정
* @returns 업로드 결과
*/
post(content, { params, ajax } = {}) {
return this.api.postWithEditToken({
...this.postParams,
...params,
text: content,
}, { ...this.postAjax, ...ajax });
}
/**
* 계속 불러오기(간격 재설정)
* @param options 간격 및 시작 리비전 설정
* @returns 이 객체 (메소드 체이닝)
*/
start({ interval = {}, from = this.now, } = {}) {
Object.assign(this.interval, interval); // 시간 간격 갱신
if (typeof this.interval !== 'object' ||
(this.interval.throttle &&
typeof this.interval.throttle !== 'number') ||
(this.interval.margin && typeof this.interval.margin !== 'number')) {
throw new TypeError('정상적인 간격이 지정되지 않았습니다');
}
const generator = this.load(from);
const load = (options) => {
// 다음 폴링
const next = () => {
if (throttle && request)
this[TIMEOUT] = setTimeout(load, this.interval.margin || 0, {
params: {
maxage: 0,
},
});
};
let throttle = false; // 최소 시간 경과 여부
let request = false; // 요청 완료 여부
this[TIMEOUT] = null;
this[RUNNING] = true;
// 일정 시간이 지나기 전에는 다음 요청을 하지 않음
this[THROTTLE] = setTimeout(() => {
if (this[STOPPED])
this[STOPPED] = false;
else {
throttle = true;
this[THROTTLE] = null;
next();
}
}, this.interval.throttle || 0);
// AJAX 요청
generator.next(options).then(({ value: result }) => {
if (this[STOPPED])
this[STOPPED] = false;
// 정지가 예약되면 이번 이벤트를 무시하고 정지 예약 해제
else {
const { response, revisions } = result;
result.error = false;
result.updated = Boolean(revisions);
// 로드 완료
super.dispatchEvent(new CustomEvent('fetch', {
detail: result,
}));
if (revisions) {
// 이번 요청에서 데이터가 갱신되었을 때
super.dispatchEvent(new CustomEvent('update', {
detail: result,
}));
if (response.continue) {
// 더 불러올 데이터가 있으면 바로 요청
return load();
}
}
request = true; // 데이터 불러옴
next(); // 다음 요청 시도
}
}, (error) => {
if (this[STOPPED])
this[STOPPED] = false;
// 정지가 예약되면 이번 이벤트를 무시하고 정지 예약 해제
else {
// 에러 이벤트
super.dispatchEvent(new CustomEvent('error', {
detail: error,
}));
// 서버에 요청이 정상적으로 보내졌으나 API 에러가 발생한 경우 (넷코드용)
if (error.type === 'api')
super.dispatchEvent(new CustomEvent('fetch', {
detail: {
response: error.response,
error: true,
updated: false,
},
}));
if (this.stopOnError)
this[RUNNING] = false; // 에러가 발생하면 정지
else {
request = true; // 데이터 불러옴
next(); // 다음 요청 시도
}
}
});
// 요청 시도 이벤트
super.dispatchEvent(new CustomEvent('request', {
detail: {
target: { ...this.now },
params: {
...this.loadParams,
...options === null || options === void 0 ? void 0 : options.params,
},
ajax: options === null || options === void 0 ? void 0 : options.ajax,
},
}));
};
if (!this[RUNNING])
load(); // 자동 요청이 실행중이 아니라면 시작
return this; // 메소드 체이닝
}
/**
* 계속 불러오기 중단
* @returns 이 객체 (메소드 체이닝)
*/
stop() {
if (this[RUNNING]) {
// 자동 요청이 실행중이면
// 필수 대기시간
if (this[TIMEOUT]) {
clearTimeout(this[TIMEOUT]);
this[TIMEOUT] = null;
}
else
this[STOPPED] = true; // 요청중이면 결과 무시 예약
// 쓰로틀 대기중이면
if (this[THROTTLE]) {
clearTimeout(this[THROTTLE]);
this[THROTTLE] = null;
}
this[RUNNING] = false; // 정지로 표시
}
else
throw new Error('자동 로드가 실행중이 아닙니다');
return this; // 메소드 체이닝
}
/**
* 현재(다음) 리비전 (지정된 리비전으로) 초기화
* @param from 변경할 현재 리비전 위치
* @returns 이 객체 (메소드 체이닝)
*/
reset(from) {
if (this[RUNNING])
this.stop(); // 일단 진행중인 요청 정지
this.now = from; // 리비전 위치 변경/초기화
this.start(); // 다시 시작
return this; // 메소드 체이닝
}
dispatchEvent() {
throw new TypeError('이벤트를 임의로 발생시킬 수 없습니다');
}
/** 자동 요청 실행 여부 */
get running() {
return this[RUNNING];
}
}
_a = RUNNING, _b = STOPPED, _c = THROTTLE, _d = TIMEOUT;
/** 기본 미디어위키 API 인터페이스 */
LiveClient.api = new mw.Api();
/** interval 기본값 */
LiveClient.interval = {
throttle: null,
margin: null,
};
/** loadParams 기본값 */
LiveClient.loadParams = {
action: 'query',
curtimestamp: true,
prop: 'revisions',
formatversion: 2,
rvprop: ['ids', 'timestamp', 'user', 'content'],
rvslots: 'main',
rvlimit: 'max',
rvdir: 'newer',
};
/** postParams 기본값 */
LiveClient.postParams = {
action: 'edit',
curtimestamp: true,
minor: true,
};
/** loadAjax 기본값 */
LiveClient.loadAjax = {};
/** postAjax 기본값 */
LiveClient.postAjax = {};
/** 레이턴시 기록 및 보정 대기 시간 산출 클래스 */
class Latency {
constructor() {
/** 최근 레이턴시 기록 */
this.history = [];
/** 다른 사용자의 레이턴시 */
this.peers = [];
}
/** 마지막 접속 레이턴시 */
get last() {
var _g;
return (_g = this.history[0]) !== null && _g !== void 0 ? _g : null;
}
// 레이턴시 기록 추가시 오래된 기록 제거 (5개로 유지)
set last(time) {
if (typeof time !== 'number')
throw new TypeError('last 속성에는 숫자만 할당할 수 있습니다');
this.history.unshift(time);
while (this.history.length > 5)
this.history.pop();
}
/** 최근 평균 레이턴시 */
get average() {
if (!this.history.length)
return null;
return (this.history.reduce((sum, cur) => sum + cur) / this.history.length);
}
/** 자동 산출된 보정 대기 시간 */
get delay() {
if (!this.history.length)
return null;
return Math.max(
// 평균값, 최댓값의 중간값
(this.peers.reduce((sum, cur) => sum + cur, this.average) /
(this.peers.length + 1) + // 평균
Math.max(...this.peers)) /
2 -
this.average, // 자신의 평균 핑과의 차
0 // 자연수가 아니면 0
);
}
}
/** 보정 대기 시간을 자동으로 적용하는 클라이언트 래퍼 */
export class Netcode {
/**
* Netcode 객체 생성자
* @param client 넷코드를 연결할 클라이언트 및 클라이언트를 연결할 문서
* @param config 넷코드 설정 객체
*/
constructor(client, config = {}) {
/** 다운로드 시작 시간 */
this[_e] = [];
if (!client)
throw new TypeError('연결할 클라이언트나 문서를 지정하지 않았습니다');
this.client =
client instanceof LiveClient ? client : new LiveClient(client);
this.running = false;
this.upload = new Latency();
this.download = new Latency();
this.gateway = config.gateway || null;
this.event =
config.event || this.constructor.event;
this[REQUEST] = this[REQUEST].bind(this);
this[FETCH] = this[FETCH].bind(this);
if (typeof config.report === 'function')
this.report = config.report.bind(this);
}
/**
* 자동 요청 시작
* @param delay 최소 지연 시간
* @param from 시작 리비전
*/
start(delay, from) {
this.client.start({ interval: delay, from });
if (!this.gateway)
this[TIMEOUT] = setTimeout(this.report, 30000);
this.client.addEventListener('request', this[REQUEST]);
this.client.addEventListener('fetch', this[FETCH]);
this.running = true;
}
/**
* 자동 레이턴시 보정 적용 해제
* @param stopClient 클라이언트 자동 요청도 중단 (기본적으로 활성화됨)
*/
stop(stopClient = true) {
if (stopClient)
this.client.stop();
clearTimeout(this[TIMEOUT]);
this.client.removeEventListener('request', this[REQUEST]);
this.client.removeEventListener('fetch', this[FETCH]);
this.running = false;
}
/**
* 데이터 전송
* @param content 내용
*/
async post(content) {
let before;
const start = Date.now();
if (typeof this.upload.last === 'number') {
before = this.upload.average;
await timeout(this.upload.delay);
}
const result = await this.client.post(content);
this.upload.last =
new Date(result.edit.newtimestamp).getTime() - start;
if (!this.gateway &&
typeof before === 'number' &&
this.download.average !== null &&
Math.abs(this.download.average - before) > 5000)
this.report();
}
/** 레이턴시 정보 전송 */
report() {
var _g;
return (_g = this.gateway) === null || _g === void 0 ? void 0 : _g.post(this.event, {
up: this.upload.average,
down: this.download.average,
});
}
/**
* 요청 시도시 소요시간 측정
* @param event 이벤트 객체
*/
[(_e = DOWN_START, REQUEST)](event) {
this[DOWN_START].push(Date.now());
}
/**
* 다운로드 완료시 소요시간 측정
* @param event 이벤트 객체
*/
[FETCH](event) {
const before = this.download.average;
if (this[DOWN_START].length)
this.download.last = Date.now() - this[DOWN_START].shift();
if (this.download.delay !== null)
this.client.interval.throttle = this.download.delay;
if (!this.gateway &&
before !== null &&
this.download.average !== null &&
Math.abs(this.download.average - before) > 5000)
this.report();
}
}
/** 레이턴시 공유 이벤트 종류 이름 기본값 */
Netcode.event = 'connect';
/**
* Promise를 사용하는 일시 중단 함수
* @param sec 일시 중단 시간 (ms)
* @returns Promise
*/
function timeout(sec) {
return new Promise((resolve) => setTimeout(resolve, sec));
}
/**
* 서버 이벤트를 자동으로 실행
*
* LiveEventDistributor, LiveEventGateway의 기반 클래스. 직접 사용하지 않는 상속용 클래스
*
* EventTarget과 유사한 구조를 가지고 있지만, EventTarget을 상속받지 않고 Event 객체를 받지 않는 클래스이므로 주의하세요.
*/
class LiveEventTarget {
/**
* LiveEventTarget 객체 생성자
* @param events 등록할 액션
* @param config 추가 설정
*/
constructor(events = {}, config = {}) {
/** 등록된 이벤트 */
this.listeners = {};
this.onceListeners = {};
this.handle = (config.handle || this.handle).bind(this);
if (events)
this.addEventListener(events);
}
addEventListener(typeOrListeners, callbackOrOptions, options) {
switch (typeof typeOrListeners) {
case 'object': // 객체를 통해 여러 타입의 리스너를 추가할 경우
for (const type in typeOrListeners)
this.addEventListener(type, typeOrListeners[type], callbackOrOptions);
break;
case 'string': // 하나의 리스너를 추가할 경우
switch (typeof callbackOrOptions) {
case 'object': // 두번째 인자가 객체일 때 handleEvent가 있는지 확인하여 리스너 객체인지 확인
if (!('handleEvent' in callbackOrOptions))
// 리스너 객체가 아니면 타입 오류
throw new TypeError(`${typeOrListeners} 타입에 대한 이벤트 리스너가 올바르지 않습니다`);
// fallthrough // 리스너 객체면 속행
case 'function':
if (options === null || options === void 0 ? void 0 : options.once) {
if (!this.listeners[typeOrListeners])
this.listeners[typeOrListeners] = new Set();
}
else {
if (!this.onceListeners[typeOrListeners])
this.onceListeners[typeOrListeners] = new Set();
}
((options === null || options === void 0 ? void 0 : options.once)
? this.onceListeners[typeOrListeners]
: this.listeners[typeOrListeners]).add(callbackOrOptions);
break;
default:
throw new TypeError(`${typeOrListeners} 타입에 대한 이벤트 리스너가 올바르지 않습니다`);
}
break;
default:
throw new TypeError('addEventListener 메소드에 올바른 인자를 넣지 않았습니다');
}
return this;
}
removeEventListener(type, callback, options) {
var _g;
if (typeof type === 'string') {
// 첫번째 인자에 하나의 type만 넣을 경우
if (options && 'once' in options) {
// option.once 지정시 onceListeners 또는 listeners 둘 중 하나 제거
const listeners = (options === null || options === void 0 ? void 0 : options.once)
? this.listeners
: this.onceListeners;
if (callback)
// callback 지정시 지정된 callback만 제거
(_g = listeners[type]) === null || _g === void 0 ? void 0 : _g.delete(callback);
else
delete listeners[type]; // 미지정시 해당 type의 모든 callback 제거
}
else {
// option.once 미지정시 onceListeners 또는 listeners 둘 다 제거
this.removeEventListener(type, callback, { once: false });
this.removeEventListener(type, callback, { once: true });
}
}
else if (type.forEach)
// 첫번째 인자에 배열이나 Set을 통해 여러 type을 보낼 경우 모든 type 제거
type.forEach((type) => this.removeEventListener(type, null, callback));
else
throw new TypeError('removeEventListener 메소드에 올바른 인자를 넣지 않았습니다');
return this;
}
dispatchEvent(type, arg, rev) {
var _g, _h;
const run = (listener) => {
try {
switch (typeof listener) {
case 'function':
listener(arg, rev);
break;
case 'object':
listener.handleEvent(arg, rev);
break;
}
}
catch (error) {
console.error('Uncaught', error);
window.dispatchEvent(new ErrorEvent('error', error));
}
};
(_g = this.listeners[type]) === null || _g === void 0 ? void 0 : _g.forEach(run);
(_h = this.onceListeners[type]) === null || _h === void 0 ? void 0 : _h.forEach(run);
delete this.onceListeners[type];
}
/**
* 폴링 데이터를 받을 때 실행되어 이벤트를 실행하는 클라이언트 이벤트 핸들러
* @param event 이벤트 객체
*/
handle({ detail }) {
console.log(...arguments);
for (const rev of detail.revisions) {
const content = rev.slots.main.content;
const sep = content.search(' ');
const type = content.slice(0, sep);
const arg = parseJSON(content.slice(sep + 1));
this.dispatchEvent(type, arg, rev);
}
}
}
/**
* 서버 이벤트를 자동으로 실행
*
* 여러 클라이언트를 연결해 서버 이벤트를 분배할 수 있지만, 이벤트를 서버에 전송할 수 없습니다. LiveEventGateway를 이용해 보세요.
*
* EventTarget과 유사한 구조를 가지고 있지만, EventTarget을 상속받지 않고 Event 객체를 받지 않는 클래스이므로 주의하세요.
*/
export class LiveEventDistributor extends LiveEventTarget {
/**
* LiveEventDistributor 객체 생성자
* @param clients 연결할 클라이언트
* @param events 등록할 액션
* @param config 추가 설정
*/
constructor(clients, events = {}, config = {}) {
super(events, config);
/** 연결된 클라이언트 */
this[_f] = new Set();
if (clients)
this.connect(clients);
}
/** 클라이언트 목록 불러오기 */
get clients() {
return [...this[CLIENTS]];
}
/**
* 클라이언트 연결
* @param clients 연결할 클라이언트
* @returns 이 객체 (메소드 체이닝)
*/
connect(...clients) {
if (Array.isArray(clients[0]) || clients[0] instanceof Set)
clients = clients[0];
for (const client of clients) {
if (!(client instanceof LiveClient))
throw new TypeError('EventDistributor에 LiveClient가 아닌 객체를 연결하려 시도했습니다');
if (this[CLIENTS].has(client))
continue;
this[CLIENTS].add(client);
client.addEventListener('update',
this.handle);
}
return this;
}
/**
* 클라이언트 연결 해제
* @param clients 연결 해제할 클라이언트
* @returns 이 객체 (메소드 체이닝)
*/
disconnect(...clients) {
if (Array.isArray(clients[0]) || clients[0] instanceof Set)
clients = clients[0];
if (clients.length <= 0)
clients = this.clients;
for (const client of clients) {
if (!(client instanceof LiveClient))
throw new TypeError('disconnect 메소드에 잘못된 인자를 넣었습니다');
if (!this[CLIENTS].has(client))
continue;
client.removeEventListener('update',
this.handle);
this[CLIENTS].delete(client);
}
return this;
}
}
_f = CLIENTS;
/**
* 서버 이벤트를 자동으로 실행
*
* 하나의 클라이언트를 연결해 이벤트를 분배받고, 서버에 이벤트를 전송할 수 있습니다. 여러 클라이언트를 연결하려면 LiveEventDistributor를 이용해 보세요.
*
* EventTarget과 유사한 구조를 가지고 있지만, EventTarget을 상속받지 않고 Event 객체를 받지 않는 클래스이므로 주의하세요.
*/
export class LiveEventGateway extends LiveEventTarget {
/**
* LiveEventGateway 객체 생성자
* @param client 연결할 클라이언트
* @param events 등록할 액션
* @param config 추가 설정
*/
constructor(client, events = {}, config = {}) {
if (!(client instanceof LiveClient))
throw new TypeError('EventGateway에 LiveClient가 아닌 객체를 연결하려 시도했습니다');
super(events, config);
this.client = client;
client.addEventListener('update',
this.handle);
}
/**
* 이벤트 전송
* @param type 이벤트 타입 이름
* @param content 이벤트 인자값
* @returns 업로드 결과
*/
post(type, content) {
return this.client.post(`${type} ${JSON.stringify(content)}`);
}
}