From 0558fc20b788faef11813d116e41533f7f2ab43e Mon Sep 17 00:00:00 2001 From: Austin Hamilton Date: Sun, 4 Oct 2026 23:11:54 +0000 Subject: [PATCH 1/4] docs(core): fix controller hub example API --- packages/core/README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 8dc45a1..2fd00bd 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -51,13 +51,13 @@ import { createControllerHub } from "@opencontroller/core"; const hub = await createControllerHub(); -const playerOne = await hub.createController({ +const playerOne = await hub.add({ id: "player-1", profile: "xbox", adapter: "dry-run", }); -const playerTwo = await hub.createController({ +const playerTwo = await hub.add({ id: "player-2", profile: "xbox", adapter: "dry-run", @@ -68,6 +68,11 @@ await playerTwo.press("B", 80); await hub.disconnectAll(); ``` +Hub IDs are caller-assigned logical identifiers for controllers in this hub. +They do not identify physical devices or host/browser slots. Use `hub.get(id)` +to retrieve an added controller; `hub.disconnectAll()` disconnects every +controller and clears the hub so those IDs can be added again. + ## Analog Button Pressure ```ts From ac57bca40bb241202f9d786af92eaca9b0bc0ee1 Mon Sep 17 00:00:00 2001 From: Austin Hamilton Date: Sun, 4 Oct 2026 23:11:54 +0000 Subject: [PATCH 2/4] test(core): cover hub logical ID lifecycle --- .../core/src/__tests__/controller.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/core/src/__tests__/controller.test.ts b/packages/core/src/__tests__/controller.test.ts index 698e632..5ade97c 100644 --- a/packages/core/src/__tests__/controller.test.ts +++ b/packages/core/src/__tests__/controller.test.ts @@ -2470,6 +2470,31 @@ describe("controller runtime", () => { await hub.disconnectAll(); }); + + test("rejects duplicate logical hub IDs and allows reuse after disconnectAll", async () => { + const hub = await createControllerHub(); + const options = { + id: "caller-owned-player", + profile: "xbox" as const, + adapter: "dry-run" as const, + replay: false, + }; + const first = await hub.add(options); + + await expect(hub.add(options)).rejects.toThrow( + "Controller caller-owned-player already exists", + ); + expect(hub.get(options.id)).toBe(first); + + await hub.disconnectAll(); + expect(hub.has(options.id)).toBe(false); + + const reused = await hub.add(options); + expect(hub.get(options.id)).toBe(reused); + expect(reused).not.toBe(first); + + await hub.disconnectAll(); + }); }); async function waitFor(predicate: () => boolean): Promise { From 68d2f771b95f0f55ae58edf523972272b3862eae Mon Sep 17 00:00:00 2001 From: Austin Hamilton Date: Sun, 4 Oct 2026 23:20:25 +0000 Subject: [PATCH 3/4] fix(core): reserve concurrent controller hub IDs --- examples/basic-dry-run/controller-hub.ts | 20 ++++++ packages/core/README.md | 8 +-- .../core/src/__tests__/controller.test.ts | 62 ++++++++++++++++++- packages/core/src/hub.ts | 14 +++-- 4 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 examples/basic-dry-run/controller-hub.ts diff --git a/examples/basic-dry-run/controller-hub.ts b/examples/basic-dry-run/controller-hub.ts new file mode 100644 index 0000000..70b1798 --- /dev/null +++ b/examples/basic-dry-run/controller-hub.ts @@ -0,0 +1,20 @@ +import { createControllerHub } from "@opencontroller/core"; + +// Typed counterpart to the Multi-Controller Hubs example in the core README. +const hub = await createControllerHub(); + +await hub.add({ + id: "player-1", + profile: "xbox", + adapter: "dry-run", +}); + +await hub.add({ + id: "player-2", + profile: "xbox", + adapter: "dry-run", +}); + +await hub.get("player-1").press("A", 80); +await hub.get("player-2").press("B", 80); +await hub.disconnectAll(); diff --git a/packages/core/README.md b/packages/core/README.md index 2fd00bd..449ef4e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -51,20 +51,20 @@ import { createControllerHub } from "@opencontroller/core"; const hub = await createControllerHub(); -const playerOne = await hub.add({ +await hub.add({ id: "player-1", profile: "xbox", adapter: "dry-run", }); -const playerTwo = await hub.add({ +await hub.add({ id: "player-2", profile: "xbox", adapter: "dry-run", }); -await playerOne.press("A", 80); -await playerTwo.press("B", 80); +await hub.get("player-1").press("A", 80); +await hub.get("player-2").press("B", 80); await hub.disconnectAll(); ``` diff --git a/packages/core/src/__tests__/controller.test.ts b/packages/core/src/__tests__/controller.test.ts index 5ade97c..8def361 100644 --- a/packages/core/src/__tests__/controller.test.ts +++ b/packages/core/src/__tests__/controller.test.ts @@ -2461,13 +2461,23 @@ describe("controller runtime", () => { ], }); - await hub.get("player-1").press("A", 5); - await hub.get("player-2").press("X", 5); + const playerOne = hub.get("player-1"); + const playerTwo = hub.get("player-2"); + + await playerOne.setButton("A", true); + expect(playerOne.getState().buttons.A).toBe(true); + expect(playerTwo.getState().buttons.A).toBe(false); + + await playerTwo.setButton("X", true); + expect(playerTwo.getState().buttons.X).toBe(true); + expect(playerOne.getState().buttons.X).toBe(false); expect(hub.list()).toEqual(["player-1", "player-2"]); expect(hub.states()["player-1"]?.profile).toBe("xbox"); expect(hub.states()["player-2"]?.profile).toBe("xbox"); + await playerOne.setButton("A", false); + await playerTwo.setButton("X", false); await hub.disconnectAll(); }); @@ -2495,6 +2505,54 @@ describe("controller runtime", () => { await hub.disconnectAll(); }); + + test("reserves hub IDs while controller creation is pending", async () => { + const hub = await createControllerHub(); + const options = { + id: "concurrent-player", + profile: "xbox" as const, + adapter: "dry-run" as const, + replay: false, + }; + + const results = await Promise.allSettled([ + hub.add(options), + hub.add(options), + ]); + const createdControllers = results.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + + await Promise.all( + createdControllers.map((controller) => controller.disconnect()), + ); + + expect(results[0]?.status).toBe("fulfilled"); + expect(results[1]?.status).toBe("rejected"); + if (results[1]?.status === "rejected") { + expect(results[1].reason).toMatchObject({ + message: "Controller concurrent-player already exists", + }); + } + }); + + test("releases a reserved hub ID when controller creation fails", async () => { + const hub = await createControllerHub(); + const options = { + id: "retry-after-failure", + profile: "xbox" as const, + adapter: "websocket" as const, + replay: false, + }; + + await expect(hub.add(options)).rejects.toThrow( + "WebSocket adapter requires a url option", + ); + + const controller = await hub.add({ ...options, adapter: "dry-run" }); + expect(hub.get(options.id)).toBe(controller); + await hub.disconnectAll(); + }); }); async function waitFor(predicate: () => boolean): Promise { diff --git a/packages/core/src/hub.ts b/packages/core/src/hub.ts index 7c848e4..18996e2 100644 --- a/packages/core/src/hub.ts +++ b/packages/core/src/hub.ts @@ -11,15 +11,21 @@ export type ControllerHubOptions = { export class ControllerHub { private readonly controllers = new Map(); + private readonly pendingIds = new Set(); async add(options: ControllerHubEntry): Promise { - if (this.controllers.has(options.id)) { + if (this.controllers.has(options.id) || this.pendingIds.has(options.id)) { throw new Error(`Controller ${options.id} already exists`); } - const controller = await createController(options); - this.controllers.set(options.id, controller); - return controller; + this.pendingIds.add(options.id); + try { + const controller = await createController(options); + this.controllers.set(options.id, controller); + return controller; + } finally { + this.pendingIds.delete(options.id); + } } get(id: string): Controller { From 13dd7a429b7b351453812abb86bc68bcea163c69 Mon Sep 17 00:00:00 2001 From: Austin Hamilton Date: Sun, 4 Oct 2026 23:23:32 +0000 Subject: [PATCH 4/4] docs(core): clarify pending hub add cleanup --- examples/basic-dry-run/controller-hub.ts | 1 + packages/core/README.md | 5 +++-- packages/core/src/__tests__/controller.test.ts | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/basic-dry-run/controller-hub.ts b/examples/basic-dry-run/controller-hub.ts index 70b1798..2737b03 100644 --- a/examples/basic-dry-run/controller-hub.ts +++ b/examples/basic-dry-run/controller-hub.ts @@ -17,4 +17,5 @@ await hub.add({ await hub.get("player-1").press("A", 80); await hub.get("player-2").press("B", 80); +// Await every in-flight hub.add() before disconnecting registered controllers. await hub.disconnectAll(); diff --git a/packages/core/README.md b/packages/core/README.md index 449ef4e..d330811 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -70,8 +70,9 @@ await hub.disconnectAll(); Hub IDs are caller-assigned logical identifiers for controllers in this hub. They do not identify physical devices or host/browser slots. Use `hub.get(id)` -to retrieve an added controller; `hub.disconnectAll()` disconnects every -controller and clears the hub so those IDs can be added again. +to retrieve an added controller. Await every in-flight `hub.add()` before +calling `hub.disconnectAll()`: it disconnects the controllers currently +registered with the hub and clears those IDs so they can be added again. ## Analog Button Pressure diff --git a/packages/core/src/__tests__/controller.test.ts b/packages/core/src/__tests__/controller.test.ts index 8def361..13e2ada 100644 --- a/packages/core/src/__tests__/controller.test.ts +++ b/packages/core/src/__tests__/controller.test.ts @@ -2523,10 +2523,6 @@ describe("controller runtime", () => { result.status === "fulfilled" ? [result.value] : [], ); - await Promise.all( - createdControllers.map((controller) => controller.disconnect()), - ); - expect(results[0]?.status).toBe("fulfilled"); expect(results[1]?.status).toBe("rejected"); if (results[1]?.status === "rejected") { @@ -2534,6 +2530,10 @@ describe("controller runtime", () => { message: "Controller concurrent-player already exists", }); } + expect(createdControllers).toHaveLength(1); + expect(hub.get(options.id)).toBe(createdControllers[0]); + + await hub.disconnectAll(); }); test("releases a reserved hub ID when controller creation fails", async () => {