@tai-kun/surrealdb
Version:
The SurrealDB SDK for JavaScript
272 lines (242 loc) • 6.78 kB
text/typescript
import type Client from "@tai-kun/surrealdb/basic-client";
import type { EngineEventMap } from "@tai-kun/surrealdb/engine";
import { WebSocketEngineError } from "@tai-kun/surrealdb/errors";
import { TaskEmitter } from "@tai-kun/surrealdb/utils";
export type AutoReconnectEventMap = {
enqueue: [endpoint: URL];
pending: [endpoint: URL, duration: number];
connect: [endpoint: URL];
success: [endpoint: URL];
failure: [endpoint: URL, error: unknown];
error: [error: unknown];
};
// TODO(tai-kun): ステータスとフェーズのイベントリスナー必要?
// & {
// [P in `state:*`]: [event: {
// type: ReconnectionState;
// endpoint: URL;
// }];
// }
// & {
// [P in `phase:*`]: [event: {
// type: ReconnectionPhase;
// endpoint: URL;
// }];
// }
// & {
// [P in `state:${ReconnectionState}`]: [event: {
// type: ReconnectionState;
// endpoint: URL;
// }];
// }
// & {
// [P in `phase:${ReconnectionPhase}`]: [event: {
// type: ReconnectionPhase;
// endpoint: URL;
// }];
// };
export interface AutoReconnectOptions {
readonly backoffLimit?: number | undefined;
readonly initialDelay?: number | undefined;
readonly maxDelay?: number | undefined;
readonly shouldReconnect?:
| {
readonly ping?:
| {
readonly threshold: number;
readonly perMillis: number;
}
| undefined;
}
| ((...args: EngineEventMap["error"]) => boolean)
| undefined;
}
// state
// - waiting: 初期状態
// - running: 実行中
// - success: 最終的な再接続の結果 (成功)
// - failure: 最終的な再接続の結果 (失敗)
// phase
// - waiting: 初期状態
// - pending: 再接続する時が来るまで待機
// - closing: 切断中
// - connecting: 再接続中
// - succeeded: 再接続に成功した
// - failed: 再接続に失敗した
export type ReconnectionInfo = {
state: "waiting";
phase: "waiting" | "pending";
} | {
state: "running";
phase: "closing" | "connecting";
} | {
state: "success";
phase: "pending" | "succeeded";
} | {
state: "failure";
phase: "pending" | "failed";
};
export type ReconnectionState = ReconnectionInfo["state"];
export type ReconnectionPhase = ReconnectionInfo["phase"];
class AutoReconnect extends TaskEmitter<AutoReconnectEventMap> {
readonly db: Client;
readonly maxDelay: number;
readonly backoffLimit: number;
readonly initialDelay: number;
readonly shouldReconnect: (...args: EngineEventMap["error"]) => boolean;
private _enabled = true;
private _counter = 0;
private _info: ReconnectionInfo = {
state: "waiting",
phase: "waiting",
};
constructor(db: Client, options: AutoReconnectOptions | undefined = {}) {
super();
this.db = db;
const {
maxDelay = 30_000,
backoffLimit = Infinity,
initialDelay = 500,
shouldReconnect = {},
} = options;
this.maxDelay = maxDelay;
this.backoffLimit = backoffLimit;
this.initialDelay = initialDelay;
if (typeof shouldReconnect === "function") {
this.shouldReconnect = shouldReconnect;
} else {
const {
ping = {
// デフォルトで「1 分以内に 3 回以上 ping に失敗した場合は再接続すべき」
threshold: 3,
perMillis: 60_000,
},
} = shouldReconnect;
const {
threshold,
perMillis,
} = ping;
let time = Date.now();
let counter = 0;
this.shouldReconnect = e => {
const now = Date.now();
if (now - time > perMillis) {
time = now;
counter = 0;
}
// エラーイベントのステータスコードが、サーバーに接続できる可能性があることを示しているなら
// 再接続を試みる。
if (e instanceof WebSocketEngineError) {
// ping に失敗
if (e.code === 3153) {
counter += 1;
}
if (
e.code === 1012 // サーバーが再起動するため、接続を一旦終了
|| e.code === 1013 // サーバーが過負荷のため、一部のクライアントとの接続を終了
) {
time = now;
counter = 0;
return true;
}
}
if (counter >= threshold) {
time = now;
counter = 0;
return true;
}
return false;
};
}
this.on("failure", (_, endpoint) => {
this._info = {
state: "failure",
phase: "failed",
};
this.emit("enqueue", endpoint);
});
this.on("success", () => {
this._info = {
state: "success",
phase: "succeeded",
};
this.reset();
});
this.on("connect", async ({ signal }, endpoint) => {
this._info = {
state: "running",
phase: "closing",
};
try {
await this.db.close({ signal });
} catch (e) {
this.emit("error", e);
}
this._info = {
state: "running",
phase: "connecting",
};
try {
await this.db.connect(endpoint, { signal });
this.emit("success", endpoint);
} catch (e) {
this.emit("failure", endpoint, e);
}
});
this.on("pending", ({ signal }, endpoint, duration) => {
this._info.phase = "pending";
const t = setTimeout(() => this.emit("connect", endpoint), duration);
signal.addEventListener("abort", () => clearTimeout(t), { once: true });
});
this.on("enqueue", (_, endpoint) => {
const duration = Math.min(
this.initialDelay * (2 ** this._counter++),
this.maxDelay,
);
this.emit("pending", endpoint, duration);
});
this.db.on("error", (_, e) => {
if (
this.enabled
&& this.state !== "running"
// TODO(tai-kun): 上限に達したら error イベントだす?
&& this._counter < this.backoffLimit
&& this.shouldReconnect(e)
) {
const { endpoint } = this.db;
if (endpoint) {
this.emit("enqueue", endpoint);
}
}
});
}
getReconnectionInfo(): ReconnectionInfo {
return Object.assign({}, this._info);
}
get state(): ReconnectionState {
return this._info.state;
}
get phase(): ReconnectionPhase {
return this._info.phase;
}
get enabled(): boolean {
return this._enabled;
}
enable(): void {
this._enabled = true;
}
disable(): void {
this._enabled = false;
}
reset(): void {
this._counter = 0;
}
}
/**
* @experimental
*/
export default function autoReconnect(
...args: ConstructorParameters<typeof AutoReconnect>
): AutoReconnect {
return new AutoReconnect(...args);
}