From b055c247d1bd6466a0a34b1be611c6fbdfacfdbd Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:04:10 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20AuthState=E3=81=AB"refreshing"?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97TCNetConfiguration=E3=82=92?= =?UTF-8?q?=E6=8B=A1=E5=BC=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 認証の自動リフレッシュ機能の基盤として、AuthState型に "refreshing"状態を追加し、TCNetConfigurationにautoReauth (デフォルトtrue)とreauthInterval(デフォルト60000ms)を追加する。 --- src/auth.ts | 2 +- src/tcnet.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/auth.ts b/src/auth.ts index bd930ec..3028874 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -21,7 +21,7 @@ export const DATA_HASH = 0xc688a0af; * TCNASDP認証の状態を表す型 * @category Auth */ -export type AuthState = "none" | "pending" | "authenticated" | "failed"; +export type AuthState = "none" | "pending" | "authenticated" | "refreshing" | "failed"; /** * FNV-1a Int32変種ハッシュ関数 diff --git a/src/tcnet.ts b/src/tcnet.ts index 790bd1d..6fdd225 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -10,6 +10,7 @@ import { generateAuthPayload, type AuthState } from "./auth"; const TCNET_BROADCAST_PORT = 60000; const TCNET_TIMESTAMP_PORT = 60001; const AUTH_RESPONSE_TIMEOUT = 5000; +const AUTH_REFRESH_TIMEOUT = 100_000; type STORED_REQUEST = { resolve: (value: nw.TCNetDataPacket | PromiseLike) => void; @@ -54,6 +55,14 @@ export class TCNetConfiguration { switchRetryInterval = 1000; /** XTEA暗号文 (16桁hex文字列)。設定時はTCNASDP認証を実行する。環境変数 TCNET_XTEA_CIPHERTEXT で上書き可能 */ xteaCiphertext?: string = process.env.TCNET_XTEA_CIPHERTEXT; + /** 認証の自動リフレッシュを有効にするかどうか。xteaCiphertext設定時、初回認証成功後にタイマーが起動する */ + autoReauth = true; + /** + * 自動リフレッシュの実行間隔 (ミリ秒) + * Bridgeの認証タイムアウト(約100秒)より短く設定する必要がある。 + * デフォルト60000ms(60秒)はv3a実機テストで240秒間のLICENSE維持が実証された値である + */ + reauthInterval = 60_000; } /** From 8a7532ce2ed91bd0fe432095c832d9f6972e4f50 Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:07:15 +0900 Subject: [PATCH 02/16] =?UTF-8?q?test:=20=E5=86=8D=E8=AA=8D=E8=A8=BC=20per?= =?UTF-8?q?formReauth=20=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=20(Red)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/auth.test.ts | 83 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 12056c2..bb839d3 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -268,6 +268,24 @@ class AuthTestClient extends TCNetClient { public clearXteaCiphertext(): void { (this as any).config.xteaCiphertext = undefined; } + public getReauthPromise(): Promise | null { + return (this as any).reauthPromise; + } + public async callPerformReauth(): Promise { + return (this as any).performReauth(); + } + public async callReauth(timeoutMs?: number): Promise { + return (this as any).reauth(timeoutMs); + } + public callStartAutoReauth(): void { + (this as any).startAutoReauth(); + } + public callStopAutoReauth(): void { + (this as any).stopAutoReauth(); + } + public getReauthIntervalId(): NodeJS.Timeout | null { + return (this as any).reauthIntervalId; + } } function createAppDataPacket(cmd: number, token: number): TCNetApplicationDataPacket { @@ -804,3 +822,68 @@ describe("sendAuthSequence XTEA暗号文バイトリバース", () => { expect(client.getBridgeIsWindows()).toBeNull(); }); }); + +describe("performReauth", () => { + it("authenticated以外の状態では何もしない", async () => { + const client = new AuthTestClient(); + client.setAuthState("none"); + await client.callPerformReauth(); + expect(client.authenticationState).toBe("none"); + }); + + it("authenticatedからresetAuthSessionで初期化されauthenticatedイベントで復帰する", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + + const promise = client.callPerformReauth(); + // executeReauthがresetAuthSession後にstate=refreshingを設定する + // sendAuthSequenceのawaitまで同期実行されるため、ここで観測可能 + expect(client.authenticationState).toBe("refreshing"); + + // authenticatedイベントをシミュレートして再認証完了 + setTimeout(() => client.emit("authenticated"), 10); + await promise; + expect(client.authenticationState).toBe("authenticated"); + }); + + it("single-flight: reauthPromiseが存在する間は2回目がskipされる", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + + const p1 = client.callPerformReauth(); + // reauthPromiseが設定されているので2回目はskip (stateがauthenticatedでないため) + const p2 = client.callPerformReauth(); + await expect(p2).resolves.toBeUndefined(); + + setTimeout(() => client.emit("authenticated"), 10); + await p1; + }); + + it("reauthenticatedイベントが成功時に発火する", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + const handler = vi.fn(); + client.on("reauthenticated", handler); + + const promise = client.callPerformReauth(); + // 手動で認証成功をシミュレート + setTimeout(() => client.emit("authenticated"), 10); + await promise; + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("reauthFailedイベントが失敗時に発火する", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + const handler = vi.fn(); + client.on("reauthFailed", handler); + + const promise = client.callPerformReauth(); + // 手動で認証失敗をシミュレート + setTimeout(() => client.emit("authFailed"), 10); + await expect(promise).rejects.toThrow(); + + expect(handler).toHaveBeenCalledTimes(1); + }); +}); From b3fb0261f151d5bbab9b4ea2d29b02103de97b2b Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:11:56 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20performReauth/executeReauth?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resetAuthSession + sendAuthSequenceの既存フローに合流する 再認証ロジックを実装する。single-flight保証付き。 executeReauthでresetAuthSession後にstate="refreshing"を設定し、 ユーザーが再認証中を観測可能にする。 handleAuthPacketのcmd=1受理条件にrefreshingを追加する。 --- src/tcnet.ts | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/src/tcnet.ts b/src/tcnet.ts index 6fdd225..ad21d7a 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -103,6 +103,8 @@ export class TCNetClient extends EventEmitter { private sessionToken: number | null = null; private authTimeoutId: NodeJS.Timeout | null = null; private bridgeIsWindows: boolean | null = null; + private reauthIntervalId: NodeJS.Timeout | null = null; + private reauthPromise: Promise | null = null; /** * TCNetClientを初期化する @@ -903,6 +905,73 @@ export class TCNetClient extends EventEmitter { return !!ct && /^[0-9a-f]{16}$/i.test(ct); } + /** + * 再認証を実行する (内部用) + * authenticated状態でのみ動作する。single-flight保証付き。 + * @param timeoutMs - 認証タイムアウト (ms) + */ + private async performReauth(timeoutMs: number = AUTH_REFRESH_TIMEOUT): Promise { + if (this._authState !== "authenticated" || this.reauthPromise) return; + this.reauthPromise = this.executeReauth(timeoutMs); + try { + await this.reauthPromise; + } finally { + this.reauthPromise = null; + } + } + + /** + * 再認証の実行本体 + * resetAuthSession + sendAuthSequenceで既存フローに合流し、 + * authenticated/authFailedイベントの発火を待って完了判定する。 + * + * リスナー登録はsendAuthSequenceの前に行い、 + * Bridgeが即座に応答した場合でもイベントを取りこぼさないようにする。 + * @param timeoutMs - 認証タイムアウト (ms) + */ + private async executeReauth(timeoutMs: number = AUTH_REFRESH_TIMEOUT): Promise { + try { + this.resetAuthSession(); + // resetAuthSessionがstate="none"にリセットした後、"refreshing"を再設定する + // これによりユーザーがauthenticationStateで再認証中を観測できる + // handleAuthPacketはcmd=1受理条件に"refreshing"を含むため、 + // この状態でもBridgeからのtoken受信を正常に処理できる + this._authState = "refreshing"; + + // リスナーをsendAuthSequenceの前に登録する + // Bridgeが即座に応答するケース(テスト環境等)でのイベント取りこぼしを防ぐ + const authPromise = new Promise((resolve, reject) => { + const onAuth = (): void => { + cleanup(); + this._authState = "authenticated"; + resolve(); + }; + const onFail = (): void => { + cleanup(); + reject(new Error("Reauth failed")); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Reauth timeout")); + }, timeoutMs); + const cleanup = (): void => { + this.removeListener("authenticated", onAuth); + this.removeListener("authFailed", onFail); + clearTimeout(timer); + }; + this.once("authenticated", onAuth); + this.once("authFailed", onFail); + }); + + await this.sendAuthSequence(); + await authPromise; + this.emit("reauthenticated"); + } catch (err) { + this.emit("reauthFailed", err instanceof Error ? err : new Error(String(err))); + throw err; + } + } + /** 認証セッションをリセットする (再試行可能な状態に戻す) */ private resetAuthSession(): void { this._authState = "none"; @@ -983,7 +1052,7 @@ export class TCNetClient extends EventEmitter { if ( packet.cmd === 1 && this.sessionToken === null && - (this._authState === "none" || this._authState === "failed") + (this._authState === "none" || this._authState === "failed" || this._authState === "refreshing") ) { this.sessionToken = packet.token; this._authState = "pending"; From e093496123905b48b3e280abf78939395aa15471 Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:24:51 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20startAutoReauth/stopAutoReauth?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 認証成功後に自動再認証タイマーを起動する仕組みを追加する。 autoReauth=trueかつreauthInterval>=10000の場合のみ起動する。 --- src/tcnet.ts | 20 ++++++++++++++++++++ tests/auth.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/tcnet.ts b/src/tcnet.ts index ad21d7a..8692667 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -905,6 +905,26 @@ export class TCNetClient extends EventEmitter { return !!ct && /^[0-9a-f]{16}$/i.test(ct); } + /** 自動再認証タイマーを起動する */ + private startAutoReauth(): void { + if (!this.config.autoReauth || this.config.reauthInterval < 10_000) return; + if (this.reauthIntervalId) return; + this.reauthIntervalId = setInterval(() => { + this.performReauth().catch((err) => { + const error = err instanceof Error ? err : new Error(String(err)); + this.log?.error(error); + }); + }, this.config.reauthInterval); + } + + /** 自動再認証タイマーを停止する */ + private stopAutoReauth(): void { + if (this.reauthIntervalId) { + clearInterval(this.reauthIntervalId); + this.reauthIntervalId = null; + } + } + /** * 再認証を実行する (内部用) * authenticated状態でのみ動作する。single-flight保証付き。 diff --git a/tests/auth.test.ts b/tests/auth.test.ts index bb839d3..a321612 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -887,3 +887,43 @@ describe("performReauth", () => { expect(handler).toHaveBeenCalledTimes(1); }); }); + +describe("autoReauth タイマー", () => { + it("startAutoReauthでタイマーが起動する", () => { + const client = new AuthTestClient(); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 60_000; + + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).not.toBeNull(); + client.callStopAutoReauth(); + }); + + it("autoReauth=falseではタイマーが起動しない", () => { + const client = new AuthTestClient(); + (client as any).config.autoReauth = false; + + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).toBeNull(); + }); + + it("reauthInterval < 10000ではタイマーが起動しない", () => { + const client = new AuthTestClient(); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 9999; + + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).toBeNull(); + }); + + it("stopAutoReauthでタイマーが停止する", () => { + const client = new AuthTestClient(); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 60_000; + + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).not.toBeNull(); + client.callStopAutoReauth(); + expect(client.getReauthIntervalId()).toBeNull(); + }); +}); From 4cf7653e3684e08e5900889e57677b0f93bffd3f Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:28:43 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20=E5=85=AC=E9=96=8Breauth()?= =?UTF-8?q?=E3=83=A1=E3=82=BD=E3=83=83=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手動で認証をリフレッシュするための公開APIを追加する。 single-flight保証付きで、進行中の再認証にはPromiseを共有する。 --- src/tcnet.ts | 22 ++++++++++++++++++++++ tests/auth.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/tcnet.ts b/src/tcnet.ts index 8692667..50d0750 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -1117,6 +1117,28 @@ export class TCNetClient extends EventEmitter { } } + /** + * 認証を手動でリフレッシュする + * + * 自動リフレッシュが無効化されている場合、または任意のタイミングで認証を + * 更新したい場合に呼び出す。既にリフレッシュ中の場合は進行中のPromiseを返す。 + * @param timeoutMs - Bridgeからの応答待ちタイムアウト (デフォルト100000ms) + * @returns 認証完了時にresolveする + * @throws {Error} xteaCiphertext未設定、未認証、接続未確立の場合 + */ + public async reauth(timeoutMs: number = AUTH_REFRESH_TIMEOUT): Promise { + if (!this.hasValidXteaCiphertext()) { + throw new Error("xteaCiphertext not configured"); + } + if (this._authState !== "authenticated" && this._authState !== "refreshing") { + throw new Error("Cannot reauth: not authenticated"); + } + if (this.reauthPromise) { + return this.reauthPromise; + } + return this.performReauth(timeoutMs); + } + /** * データリクエストをブロードキャストで送信する * diff --git a/tests/auth.test.ts b/tests/auth.test.ts index a321612..60a0e22 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -927,3 +927,41 @@ describe("autoReauth タイマー", () => { expect(client.getReauthIntervalId()).toBeNull(); }); }); + +describe("reauth (public API)", () => { + it("未認証状態ではエラーをスローする", async () => { + const client = new AuthTestClient(); + client.setAuthState("none"); + await expect(client.callReauth()).rejects.toThrow("Cannot reauth: not authenticated"); + }); + + it("xteaCiphertext未設定ではエラーをスローする", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + client.clearXteaCiphertext(); + await expect(client.callReauth()).rejects.toThrow("xteaCiphertext not configured"); + }); + + it("authenticated状態で呼び出せる", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + + const promise = client.callReauth(); + // authenticatedイベントをシミュレート + setTimeout(() => client.emit("authenticated"), 10); + await promise; + expect(client.authenticationState).toBe("authenticated"); + }); + + it("single-flight: 進行中のreauthに相乗りする", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + + const p1 = client.callReauth(); + const p2 = client.callReauth(); + + // 両方同じPromiseを共有する + setTimeout(() => client.emit("authenticated"), 10); + await Promise.all([p1, p2]); + }); +}); From f835d8c489791ada660a0d9c2fc02fd594689ac1 Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:31:16 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat:=20=E5=88=9D=E5=9B=9E=E8=AA=8D?= =?UTF-8?q?=E8=A8=BC=E6=88=90=E5=8A=9F=E6=99=82=E3=81=ABautoReauth?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=9E=E3=83=BC=E3=82=92=E8=B5=B7=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tcnet.ts | 1 + tests/auth.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/tcnet.ts b/src/tcnet.ts index 50d0750..e1ced48 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -1104,6 +1104,7 @@ export class TCNetClient extends EventEmitter { this._authState = "authenticated"; this.log?.debug("TCNASDP authentication succeeded"); this.emit("authenticated"); + this.startAutoReauth(); } else if (b0 === 0xff && b1 === 0xff && b2 === 0x0d) { if (this.authTimeoutId) { clearTimeout(this.authTimeoutId); diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 60a0e22..71cb85f 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -451,6 +451,49 @@ describe("handleAuthPacket", () => { expect(client.authenticationState).toBe("none"); expect(client.getSessionToken()).toBeNull(); }); + + it("初回認証成功時にautoReauthタイマーが起動する", () => { + const client = new AuthTestClient(); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 60_000; + client.setAuthState("pending"); + + client.callHandleAuth(createErrorPacket(0xff, 0xff, 0xff)); + + expect(client.authenticationState).toBe("authenticated"); + expect(client.getReauthIntervalId()).not.toBeNull(); + client.callStopAutoReauth(); // クリーンアップ + }); + + it("autoReauth=falseの場合タイマーは起動しない", () => { + const client = new AuthTestClient(); + (client as any).config.autoReauth = false; + client.setAuthState("pending"); + + client.callHandleAuth(createErrorPacket(0xff, 0xff, 0xff)); + + expect(client.authenticationState).toBe("authenticated"); + expect(client.getReauthIntervalId()).toBeNull(); + }); + + it("再認証成功時にstartAutoReauthが重複呼び出しされてもタイマーが1つのまま", () => { + const client = new AuthTestClient(); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 60_000; + client.setAuthState("pending"); + + // 初回認証成功 → タイマー起動 + client.callHandleAuth(createErrorPacket(0xff, 0xff, 0xff)); + const firstTimerId = client.getReauthIntervalId(); + expect(firstTimerId).not.toBeNull(); + + // 再認証成功 (再びauthenticatedパスを通る) → タイマーは既存のまま + client.setAuthState("pending"); + client.callHandleAuth(createErrorPacket(0xff, 0xff, 0xff)); + expect(client.getReauthIntervalId()).toBe(firstTimerId); + + client.callStopAutoReauth(); // クリーンアップ + }); }); // sendAuthSequenceをモックせずに状態リセット動作を検証するヘルパー From 9b789aeec6969d82f69d4cd3c5f7eed99dab9e4d Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:32:59 +0900 Subject: [PATCH 07/16] =?UTF-8?q?feat:=20disconnect=E6=99=82=E3=81=ABautoR?= =?UTF-8?q?eauth=E3=82=BF=E3=82=A4=E3=83=9E=E3=83=BC=E3=82=92=E7=A2=BA?= =?UTF-8?q?=E5=AE=9F=E3=81=AB=E5=81=9C=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tcnet.ts | 1 + tests/auth.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/tcnet.ts b/src/tcnet.ts index e1ced48..5ab38bf 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -250,6 +250,7 @@ export class TCNetClient extends EventEmitter { this._selectedAdapter = null; this.server = null; this.detectingAdapter = false; + this.stopAutoReauth(); this.resetAuthSession(); if (this.connectTimeoutId) { clearTimeout(this.connectTimeoutId); diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 71cb85f..75173a0 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -1008,3 +1008,34 @@ describe("reauth (public API)", () => { await Promise.all([p1, p2]); }); }); + +describe("disconnect時のreauthクリーンアップ", () => { + it("stopAutoReauthが呼ばれタイマーが停止する", () => { + const client = new AuthTestClient(); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 60_000; + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).not.toBeNull(); + + // disconnectSocketsの代わりにstopAutoReauthを直接テスト + // (disconnectSocketsはソケット操作を伴うため単体テストでは呼べない) + client.callStopAutoReauth(); + expect(client.getReauthIntervalId()).toBeNull(); + }); + + it("reauthPromise進行中にresetAuthSessionが呼ばれるとPromiseがrejectされる", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + + // 再認証を開始 (authenticatedイベント待ちの状態にする) + const promise = client.callPerformReauth(); + expect(client.getReauthPromise()).not.toBeNull(); + + // disconnect相当: resetAuthSessionがauthFailedをemitし、 + // executeReauthのリスナーがcatchしてPromiseをrejectする + client.callStopAutoReauth(); + setTimeout(() => client.emit("authFailed"), 0); + await expect(promise).rejects.toThrow(); + expect(client.getReauthPromise()).toBeNull(); + }); +}); From b4708bf8d3d5a9f74b6dea41f2aff1e514a8fd92 Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:34:27 +0900 Subject: [PATCH 08/16] =?UTF-8?q?docs:=20=E8=AA=8D=E8=A8=BC=E3=83=AA?= =?UTF-8?q?=E3=83=95=E3=83=AC=E3=83=83=E3=82=B7=E3=83=A5=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=AE=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/wiki/Implementation-Status.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/wiki/Implementation-Status.md b/docs/wiki/Implementation-Status.md index 4f64086..0d99baf 100644 --- a/docs/wiki/Implementation-Status.md +++ b/docs/wiki/Implementation-Status.md @@ -77,14 +77,34 @@ CUE/BeatGrid/BigWaveForm/Artwork等のデータ取得に必要なTCNASDP認証 | `none` | 認証未開始 | | `pending` | 認証シーケンス送信済み、応答待ち | | `authenticated` | 認証成功 | +| `refreshing` | 認証リフレッシュ中 (再認証シーケンスを実行中) | | `failed` | 認証失敗 | -### イベント +### 認証イベント | イベント | 説明 | |----------|------| | `authenticated` | TCNASDP認証が成功した時に発火する | | `authFailed` | TCNASDP認証が失敗した時に発火する | +| `reauthenticated` | 認証リフレッシュが成功した時に発火する | +| `reauthFailed` | 認証リフレッシュが失敗した時に発火する | + +### 認証の自動リフレッシュ + +Bridgeは認証セッションに約100秒の有効期限を設けている。`autoReauth`を有効にすると(デフォルトで有効)、ライブラリが自動的に認証を更新して`LICENSE: EXT`を維持する。 + +```typescript +const config = new TCNetConfiguration(); +config.xteaCiphertext = "your-xtea-ciphertext"; +config.autoReauth = true; // デフォルト: true +config.reauthInterval = 60_000; // デフォルト: 60000ms (60秒) +``` + +手動で認証を更新する場合は`reauth()`メソッドを使用する。 + +```typescript +await client.reauth(); +``` ## 関連ページ From 34735b7c97bba8bcb6a7ccd4b1665a87f167820e Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:38:39 +0900 Subject: [PATCH 09/16] =?UTF-8?q?refactor:=20=E3=82=B3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AB?= =?UTF-8?q?=E5=9F=BA=E3=81=A5=E3=81=8F=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - executeReauthのonAuthから冗長な_authState代入を削除 テスト側でhandleAuthPacketの実フローを忠実にシミュレートする - startAutoReauthのsetIntervalにunref()を追加 disconnect忘れ時にプロセス終了をブロックしない --- src/tcnet.ts | 2 +- tests/auth.test.ts | 25 ++++++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/tcnet.ts b/src/tcnet.ts index 5ab38bf..df92cf7 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -916,6 +916,7 @@ export class TCNetClient extends EventEmitter { this.log?.error(error); }); }, this.config.reauthInterval); + this.reauthIntervalId.unref(); } /** 自動再認証タイマーを停止する */ @@ -964,7 +965,6 @@ export class TCNetClient extends EventEmitter { const authPromise = new Promise((resolve, reject) => { const onAuth = (): void => { cleanup(); - this._authState = "authenticated"; resolve(); }; const onFail = (): void => { diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 75173a0..015d2a6 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -884,7 +884,10 @@ describe("performReauth", () => { expect(client.authenticationState).toBe("refreshing"); // authenticatedイベントをシミュレートして再認証完了 - setTimeout(() => client.emit("authenticated"), 10); + setTimeout(() => { + client.setAuthState("authenticated"); + client.emit("authenticated"); + }, 10); await promise; expect(client.authenticationState).toBe("authenticated"); }); @@ -898,7 +901,10 @@ describe("performReauth", () => { const p2 = client.callPerformReauth(); await expect(p2).resolves.toBeUndefined(); - setTimeout(() => client.emit("authenticated"), 10); + setTimeout(() => { + client.setAuthState("authenticated"); + client.emit("authenticated"); + }, 10); await p1; }); @@ -910,7 +916,10 @@ describe("performReauth", () => { const promise = client.callPerformReauth(); // 手動で認証成功をシミュレート - setTimeout(() => client.emit("authenticated"), 10); + setTimeout(() => { + client.setAuthState("authenticated"); + client.emit("authenticated"); + }, 10); await promise; expect(handler).toHaveBeenCalledTimes(1); @@ -991,7 +1000,10 @@ describe("reauth (public API)", () => { const promise = client.callReauth(); // authenticatedイベントをシミュレート - setTimeout(() => client.emit("authenticated"), 10); + setTimeout(() => { + client.setAuthState("authenticated"); + client.emit("authenticated"); + }, 10); await promise; expect(client.authenticationState).toBe("authenticated"); }); @@ -1004,7 +1016,10 @@ describe("reauth (public API)", () => { const p2 = client.callReauth(); // 両方同じPromiseを共有する - setTimeout(() => client.emit("authenticated"), 10); + setTimeout(() => { + client.setAuthState("authenticated"); + client.emit("authenticated"); + }, 10); await Promise.all([p1, p2]); }); }); From 13056524d5a073b0bcebcc6933306e3da89c72e9 Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:54:51 +0900 Subject: [PATCH 10/16] =?UTF-8?q?fix:=20=E4=B8=A6=E5=88=97=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AB=E5=9F=BA?= =?UTF-8?q?=E3=81=A5=E3=81=8F5=E4=BB=B6=E3=81=AE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T1 [Critical]: disconnectSocketsでreauthPromise進行中なら authFailedをemitしてPromiseを確実にrejectする - T2 [High]: executeReauthのcatchでリスナー・タイマーを cleanupしsendAuthSequence失敗時のリークを防止する - T3 [Medium]: executeReauth内のsetTimeoutにunref()を追加し disconnect後のプロセス終了ブロックを防止する - T4 [Low]: reauthInterval=10000の境界値テストを追加する - T5 [Low]: reauthFailedイベントの引数仕様をドキュメントに追記する --- docs/wiki/Implementation-Status.md | 2 +- src/tcnet.ts | 17 +++++++++++++---- tests/auth.test.ts | 21 +++++++++++++++++---- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/wiki/Implementation-Status.md b/docs/wiki/Implementation-Status.md index 0d99baf..f7db301 100644 --- a/docs/wiki/Implementation-Status.md +++ b/docs/wiki/Implementation-Status.md @@ -87,7 +87,7 @@ CUE/BeatGrid/BigWaveForm/Artwork等のデータ取得に必要なTCNASDP認証 | `authenticated` | TCNASDP認証が成功した時に発火する | | `authFailed` | TCNASDP認証が失敗した時に発火する | | `reauthenticated` | 認証リフレッシュが成功した時に発火する | -| `reauthFailed` | 認証リフレッシュが失敗した時に発火する | +| `reauthFailed` | 認証リフレッシュが失敗した時に発火する (引数: `Error`) | ### 認証の自動リフレッシュ diff --git a/src/tcnet.ts b/src/tcnet.ts index df92cf7..cd2b91c 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -251,6 +251,11 @@ export class TCNetClient extends EventEmitter { this.server = null; this.detectingAdapter = false; this.stopAutoReauth(); + // reauthPromise進行中の場合、executeReauthのリスナーが + // authFailedを捕捉してPromiseをrejectできるようにする + if (this.reauthPromise) { + this.emit("authFailed"); + } this.resetAuthSession(); if (this.connectTimeoutId) { clearTimeout(this.connectTimeoutId); @@ -952,6 +957,7 @@ export class TCNetClient extends EventEmitter { * @param timeoutMs - 認証タイムアウト (ms) */ private async executeReauth(timeoutMs: number = AUTH_REFRESH_TIMEOUT): Promise { + let cleanup: (() => void) | undefined; try { this.resetAuthSession(); // resetAuthSessionがstate="none"にリセットした後、"refreshing"を再設定する @@ -964,22 +970,24 @@ export class TCNetClient extends EventEmitter { // Bridgeが即座に応答するケース(テスト環境等)でのイベント取りこぼしを防ぐ const authPromise = new Promise((resolve, reject) => { const onAuth = (): void => { - cleanup(); + doCleanup(); resolve(); }; const onFail = (): void => { - cleanup(); + doCleanup(); reject(new Error("Reauth failed")); }; const timer = setTimeout(() => { - cleanup(); + doCleanup(); reject(new Error("Reauth timeout")); }, timeoutMs); - const cleanup = (): void => { + timer.unref(); + const doCleanup = (): void => { this.removeListener("authenticated", onAuth); this.removeListener("authFailed", onFail); clearTimeout(timer); }; + cleanup = doCleanup; this.once("authenticated", onAuth); this.once("authFailed", onFail); }); @@ -988,6 +996,7 @@ export class TCNetClient extends EventEmitter { await authPromise; this.emit("reauthenticated"); } catch (err) { + cleanup?.(); this.emit("reauthFailed", err instanceof Error ? err : new Error(String(err))); throw err; } diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 015d2a6..8208f4e 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -968,6 +968,16 @@ describe("autoReauth タイマー", () => { expect(client.getReauthIntervalId()).toBeNull(); }); + it("reauthInterval = 10000 (下限ちょうど) ではタイマーが起動する", () => { + const client = new AuthTestClient(); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 10_000; + + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).not.toBeNull(); + client.callStopAutoReauth(); + }); + it("stopAutoReauthでタイマーが停止する", () => { const client = new AuthTestClient(); (client as any).config.autoReauth = true; @@ -1038,7 +1048,7 @@ describe("disconnect時のreauthクリーンアップ", () => { expect(client.getReauthIntervalId()).toBeNull(); }); - it("reauthPromise進行中にresetAuthSessionが呼ばれるとPromiseがrejectされる", async () => { + it("reauthPromise進行中にdisconnect相当の操作でPromiseがrejectされる", async () => { const client = new AuthTestClient(); client.setAuthState("authenticated"); @@ -1046,10 +1056,13 @@ describe("disconnect時のreauthクリーンアップ", () => { const promise = client.callPerformReauth(); expect(client.getReauthPromise()).not.toBeNull(); - // disconnect相当: resetAuthSessionがauthFailedをemitし、 - // executeReauthのリスナーがcatchしてPromiseをrejectする + // disconnectSocketsの実フローを再現: + // stopAutoReauth → reauthPromise存在時にauthFailed emit → resetAuthSession client.callStopAutoReauth(); - setTimeout(() => client.emit("authFailed"), 0); + if (client.getReauthPromise()) { + client.emit("authFailed"); + } + (client as any).resetAuthSession(); await expect(promise).rejects.toThrow(); expect(client.getReauthPromise()).toBeNull(); }); From 949b261394b909e9a55a20ceee61b1d22f234cd6 Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:08:00 +0900 Subject: [PATCH 11/16] =?UTF-8?q?fix:=20executeReauth=E3=81=8B=E3=82=89sen?= =?UTF-8?q?dAuthSequence=E5=91=BC=E3=81=B3=E5=87=BA=E3=81=97=E3=82=92?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendAuthSequenceはsessionToken===nullガードでresetAuthSessionを 呼び出すため、executeReauthで設定した"refreshing"状態が"none"に 上書きされていた。実機テストで以下の改善を確認: - "refreshing"状態が正しく観測可能になった - Bridgeのトークン再送が高速化(94s→2s) - 全再認証が成功(修正前は1/3のみ成功) BridgeはOptInブロードキャスト経由でトークンを再送するため、 resetAuthSessionによる状態リセットのみで再認証フローに合流できる。 --- src/tcnet.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tcnet.ts b/src/tcnet.ts index cd2b91c..f1b95b7 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -966,8 +966,10 @@ export class TCNetClient extends EventEmitter { // この状態でもBridgeからのtoken受信を正常に処理できる this._authState = "refreshing"; - // リスナーをsendAuthSequenceの前に登録する - // Bridgeが即座に応答するケース(テスト環境等)でのイベント取りこぼしを防ぐ + // リスナーを登録してBridgeからのtoken再発行を待つ + // sendAuthSequenceは呼ばない: sessionToken=nullのガードでresetAuthSessionが + // 再実行されrefreshing状態が失われるため。Bridgeは定期的なOptInブロードキャスト + // 経由でtoken(AppData cmd=1)を再送するので、明示的なhello送信は不要である const authPromise = new Promise((resolve, reject) => { const onAuth = (): void => { doCleanup(); @@ -992,7 +994,6 @@ export class TCNetClient extends EventEmitter { this.once("authFailed", onFail); }); - await this.sendAuthSequence(); await authPromise; this.emit("reauthenticated"); } catch (err) { From 26e89a83700f327cdf3cb0a1b47a110a3ce78b49 Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:22:21 +0900 Subject: [PATCH 12/16] =?UTF-8?q?chore:=20=E8=AA=8D=E8=A8=BC=E3=83=AA?= =?UTF-8?q?=E3=83=95=E3=83=AC=E3=83=83=E3=82=B7=E3=83=A5=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=AEchangeset=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/auth-refresh.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/auth-refresh.md diff --git a/.changeset/auth-refresh.md b/.changeset/auth-refresh.md new file mode 100644 index 0000000..f4782d7 --- /dev/null +++ b/.changeset/auth-refresh.md @@ -0,0 +1,12 @@ +--- +"@9c5s/node-tcnet": minor +--- + +feat: TCNASDP認証の自動リフレッシュ機能を追加 + +Bridgeの認証タイムアウト(~100秒)を回避するため、認証シーケンスを定期的に再実行してLICENSE: EXTを維持する。 + +- `AuthState`型に`"refreshing"`を追加 +- `TCNetConfiguration`に`autoReauth`(デフォルト: true)と`reauthInterval`(デフォルト: 60000ms)を追加 +- 公開`reauth()`メソッドを追加(手動リフレッシュ用、single-flight保証付き) +- `reauthenticated`/`reauthFailed`イベントを追加 From c2f6a1724d161ad8c5cb6414c612b5d5e7d141ad Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:27:28 +0900 Subject: [PATCH 13/16] =?UTF-8?q?refactor:=20PR=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AB=E5=9F=BA=E3=81=A5=E3=81=8F?= =?UTF-8?q?3=E4=BB=B6=E3=81=AE=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reauth()でreauthPromiseチェックをstate チェックの前に移動し、 再認証中のpending状態でもPromiseを返せるようにする - reauthIntervalのJSDocに10000ms下限の制約を明記する - executeReauthでresetAuthSession()の代わりに必要な状態のみ 個別リセットしbridgeIsWindowsキャッシュを保持する --- src/tcnet.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/tcnet.ts b/src/tcnet.ts index f1b95b7..d9002c7 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -60,6 +60,7 @@ export class TCNetConfiguration { /** * 自動リフレッシュの実行間隔 (ミリ秒) * Bridgeの認証タイムアウト(約100秒)より短く設定する必要がある。 + * 10000ms未満に設定された場合は自動リフレッシュが有効にならない。 * デフォルト60000ms(60秒)はv3a実機テストで240秒間のLICENSE維持が実証された値である */ reauthInterval = 60_000; @@ -959,9 +960,14 @@ export class TCNetClient extends EventEmitter { private async executeReauth(timeoutMs: number = AUTH_REFRESH_TIMEOUT): Promise { let cleanup: (() => void) | undefined; try { - this.resetAuthSession(); - // resetAuthSessionがstate="none"にリセットした後、"refreshing"を再設定する - // これによりユーザーがauthenticationStateで再認証中を観測できる + // resetAuthSessionの代わりに必要な状態のみ個別リセットする + // bridgeIsWindowsキャッシュを保持して毎回のOS判定pingを回避する + // (Bridge OSはセッション中に変わらない) + this.sessionToken = null; + if (this.authTimeoutId) { + clearTimeout(this.authTimeoutId); + this.authTimeoutId = null; + } // handleAuthPacketはcmd=1受理条件に"refreshing"を含むため、 // この状態でもBridgeからのtoken受信を正常に処理できる this._authState = "refreshing"; @@ -1142,12 +1148,12 @@ export class TCNetClient extends EventEmitter { if (!this.hasValidXteaCiphertext()) { throw new Error("xteaCiphertext not configured"); } - if (this._authState !== "authenticated" && this._authState !== "refreshing") { - throw new Error("Cannot reauth: not authenticated"); - } if (this.reauthPromise) { return this.reauthPromise; } + if (this._authState !== "authenticated") { + throw new Error("Cannot reauth: not authenticated"); + } return this.performReauth(timeoutMs); } From 558c39cd7c2c691e94ccd415b1cec08d56689276 Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:22:06 +0900 Subject: [PATCH 14/16] =?UTF-8?q?fix:=20CodeRabbit=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC2=E4=BB=B6=E3=81=AE=E6=8C=87=E6=91=98?= =?UTF-8?q?=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - startAutoReauthに接続・認証状態ガードを追加 authenticatedリスナー内でdisconnect等が呼ばれた場合に 不要なタイマー起動を防止する - executeReauthのcatchでrefreshing状態をfailedに回復 タイムアウト等の失敗後にauthenticationStateが永遠に refreshingのまま残る問題を修正する --- src/tcnet.ts | 8 ++++++++ tests/auth.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/tcnet.ts b/src/tcnet.ts index d9002c7..0c103b3 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -914,6 +914,7 @@ export class TCNetClient extends EventEmitter { /** 自動再認証タイマーを起動する */ private startAutoReauth(): void { + if (!this.connected || this._authState !== "authenticated") return; if (!this.config.autoReauth || this.config.reauthInterval < 10_000) return; if (this.reauthIntervalId) return; this.reauthIntervalId = setInterval(() => { @@ -1004,6 +1005,13 @@ export class TCNetClient extends EventEmitter { this.emit("reauthenticated"); } catch (err) { cleanup?.(); + // タイムアウト等で失敗した場合、"refreshing"のままだと + // ユーザーが永遠に再認証中と誤認する。"failed"に戻すことで + // handleAuthPacketがBridgeからのtoken再送を受理して自然回復できる + if (this._authState === "refreshing") { + this.sessionToken = null; + this._authState = "failed"; + } this.emit("reauthFailed", err instanceof Error ? err : new Error(String(err))); throw err; } diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 8208f4e..d33d799 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -454,6 +454,7 @@ describe("handleAuthPacket", () => { it("初回認証成功時にautoReauthタイマーが起動する", () => { const client = new AuthTestClient(); + (client as any).connected = true; (client as any).config.autoReauth = true; (client as any).config.reauthInterval = 60_000; client.setAuthState("pending"); @@ -478,6 +479,7 @@ describe("handleAuthPacket", () => { it("再認証成功時にstartAutoReauthが重複呼び出しされてもタイマーが1つのまま", () => { const client = new AuthTestClient(); + (client as any).connected = true; (client as any).config.autoReauth = true; (client as any).config.reauthInterval = 60_000; client.setAuthState("pending"); @@ -938,11 +940,30 @@ describe("performReauth", () => { expect(handler).toHaveBeenCalledTimes(1); }); + + it("失敗後にauthStateがfailedに戻る", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + + const promise = client.callPerformReauth(); + expect(client.authenticationState).toBe("refreshing"); + + // 手動で認証失敗をシミュレート + setTimeout(() => client.emit("authFailed"), 10); + await expect(promise).rejects.toThrow(); + + // タイムアウト等の失敗でrefreshingのまま残らずfailedに戻る + // (authFailedパスではhandleAuthPacketが先にfailedに設定するが、 + // disconnect等でrefreshingのままcatchに入った場合の回復を検証) + expect(client.authenticationState).toBe("failed"); + }); }); describe("autoReauth タイマー", () => { it("startAutoReauthでタイマーが起動する", () => { const client = new AuthTestClient(); + (client as any).connected = true; + client.setAuthState("authenticated"); (client as any).config.autoReauth = true; (client as any).config.reauthInterval = 60_000; @@ -951,6 +972,17 @@ describe("autoReauth タイマー", () => { client.callStopAutoReauth(); }); + it("未接続状態ではタイマーが起動しない", () => { + const client = new AuthTestClient(); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 60_000; + // connectedがfalse(デフォルト)の場合 + expect((client as any).connected).toBe(false); + + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).toBeNull(); + }); + it("autoReauth=falseではタイマーが起動しない", () => { const client = new AuthTestClient(); (client as any).config.autoReauth = false; @@ -970,6 +1002,8 @@ describe("autoReauth タイマー", () => { it("reauthInterval = 10000 (下限ちょうど) ではタイマーが起動する", () => { const client = new AuthTestClient(); + (client as any).connected = true; + client.setAuthState("authenticated"); (client as any).config.autoReauth = true; (client as any).config.reauthInterval = 10_000; @@ -980,6 +1014,8 @@ describe("autoReauth タイマー", () => { it("stopAutoReauthでタイマーが停止する", () => { const client = new AuthTestClient(); + (client as any).connected = true; + client.setAuthState("authenticated"); (client as any).config.autoReauth = true; (client as any).config.reauthInterval = 60_000; @@ -1037,6 +1073,8 @@ describe("reauth (public API)", () => { describe("disconnect時のreauthクリーンアップ", () => { it("stopAutoReauthが呼ばれタイマーが停止する", () => { const client = new AuthTestClient(); + (client as any).connected = true; + client.setAuthState("authenticated"); (client as any).config.autoReauth = true; (client as any).config.reauthInterval = 60_000; client.callStartAutoReauth(); From f98b6e69a8fd25fe854b3decf72e46364d472401 Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:51:35 +0900 Subject: [PATCH 15/16] =?UTF-8?q?refactor:=20CodeRabbit=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC3=E4=BB=B6=E7=9B=AE=E3=81=AE=E6=8C=87?= =?UTF-8?q?=E6=91=98=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - executeReauthのJSDocを実装に合わせて更新する (resetAuthSession/sendAuthSequenceを呼ばない設計の説明) - 否定系テストで意図したガード以外の前提条件を満たすよう修正 (connected/authState設定を追加し、対象ガードのみ検証) --- src/tcnet.ts | 10 +++++----- tests/auth.test.ts | 6 ++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/tcnet.ts b/src/tcnet.ts index 0c103b3..7754935 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -951,11 +951,11 @@ export class TCNetClient extends EventEmitter { /** * 再認証の実行本体 - * resetAuthSession + sendAuthSequenceで既存フローに合流し、 - * authenticated/authFailedイベントの発火を待って完了判定する。 - * - * リスナー登録はsendAuthSequenceの前に行い、 - * Bridgeが即座に応答した場合でもイベントを取りこぼさないようにする。 + * 認証セッションのうち必要な状態(sessionToken, authTimeoutId)のみリセットし、 + * Bridgeからのtoken再送とauthenticated/authFailedイベントを待って完了判定する。 + * resetAuthSessionは呼ばない(bridgeIsWindowsキャッシュを保持するため)。 + * sendAuthSequenceも呼ばない(sessionToken=nullガードでresetAuthSessionが + * 再実行されrefreshing状態が失われるため)。 * @param timeoutMs - 認証タイムアウト (ms) */ private async executeReauth(timeoutMs: number = AUTH_REFRESH_TIMEOUT): Promise { diff --git a/tests/auth.test.ts b/tests/auth.test.ts index d33d799..f793e8c 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -468,6 +468,7 @@ describe("handleAuthPacket", () => { it("autoReauth=falseの場合タイマーは起動しない", () => { const client = new AuthTestClient(); + (client as any).connected = true; (client as any).config.autoReauth = false; client.setAuthState("pending"); @@ -974,6 +975,7 @@ describe("autoReauth タイマー", () => { it("未接続状態ではタイマーが起動しない", () => { const client = new AuthTestClient(); + client.setAuthState("authenticated"); (client as any).config.autoReauth = true; (client as any).config.reauthInterval = 60_000; // connectedがfalse(デフォルト)の場合 @@ -985,6 +987,8 @@ describe("autoReauth タイマー", () => { it("autoReauth=falseではタイマーが起動しない", () => { const client = new AuthTestClient(); + (client as any).connected = true; + client.setAuthState("authenticated"); (client as any).config.autoReauth = false; client.callStartAutoReauth(); @@ -993,6 +997,8 @@ describe("autoReauth タイマー", () => { it("reauthInterval < 10000ではタイマーが起動しない", () => { const client = new AuthTestClient(); + (client as any).connected = true; + client.setAuthState("authenticated"); (client as any).config.autoReauth = true; (client as any).config.reauthInterval = 9999; From fb76ebbd602f202070eba45e79fa9dcfba9274ae Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Thu, 9 Apr 2026 02:05:51 +0900 Subject: [PATCH 16/16] =?UTF-8?q?test:=20performReauth=E3=81=AEsingle-flig?= =?UTF-8?q?ht=E3=83=86=E3=82=B9=E3=83=88=E3=82=B3=E3=83=A1=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=82=92=E6=AD=A3=E7=A2=BA=E3=81=AB=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auth.test.ts b/tests/auth.test.ts index f793e8c..47f8790 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -900,7 +900,7 @@ describe("performReauth", () => { client.setAuthState("authenticated"); const p1 = client.callPerformReauth(); - // reauthPromiseが設定されているので2回目はskip (stateがauthenticatedでないため) + // reauthPromiseが設定済み、かつauthStateがrefreshingに遷移済みのため2回目はskip const p2 = client.callPerformReauth(); await expect(p2).resolves.toBeUndefined();