diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 10:52:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 10:52:13 +0900 |
| commit | 17ce47987e673b6618454d033885b17b2a01912e (patch) | |
| tree | 2bd7259d51725eeadc20d410667d529f66834197 /src/adapters | |
| parent | eff289b2b4cc41db706f3c3274a089d46012be87 (diff) | |
| download | dispatch-web-17ce47987e673b6618454d033885b17b2a01912e.tar.gz dispatch-web-17ce47987e673b6618454d033885b17b2a01912e.zip | |
feat(core): chunks retry-banner, wire conformance, ws logic, history adapter
- core/chunks: add retry-banner view-model (+test), update reducer/selectors/types
- core/wire: update conformance checks (+test)
- adapters/ws: update reconnect logic (+tests)
- adapters/history: new client-side routing adapter (history + popstate wrapper)
Diffstat (limited to 'src/adapters')
| -rw-r--r-- | src/adapters/history/index.test.ts | 98 | ||||
| -rw-r--r-- | src/adapters/history/index.ts | 93 | ||||
| -rw-r--r-- | src/adapters/ws/index.test.ts | 11 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 69 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 4 |
5 files changed, 270 insertions, 5 deletions
diff --git a/src/adapters/history/index.test.ts b/src/adapters/history/index.test.ts new file mode 100644 index 0000000..dec2323 --- /dev/null +++ b/src/adapters/history/index.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { createHistoryAdapter, type HistoryWindow } from "./index"; + +/** + * A minimal in-memory `HistoryWindow` fake for deterministic tests. `pathname` + * is a closure variable reflected through the `location` getter; `back(url)` + * simulates an external navigation (back/forward): it sets the path + fires the + * popstate listeners. + */ +function fakeWindow(initial = "/"): HistoryWindow & { back(url: string): void } { + let pathname = initial; + const popstateListeners = new Set<() => void>(); + return { + get location() { + return { + get pathname() { + return pathname; + }, + }; + }, + history: { + pushState(_data, _unused, url) { + if (typeof url === "string") pathname = url; + }, + replaceState(_data, _unused, url) { + if (typeof url === "string") pathname = url; + }, + }, + addEventListener(type, listener) { + if (type === "popstate") popstateListeners.add(listener); + }, + removeEventListener(type, listener) { + if (type === "popstate") popstateListeners.delete(listener); + }, + back(url: string) { + pathname = url; + for (const l of popstateListeners) l(); + }, + }; +} + +describe("createHistoryAdapter", () => { + it("reads the current path", () => { + const w = fakeWindow("/my-ws"); + const h = createHistoryAdapter({ window: w }); + expect(h.path).toBe("/my-ws"); + }); + + it("navigate updates the path + notifies subscribers", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.navigate("/default"); + expect(h.path).toBe("/default"); + expect(seen).toEqual(["/default"]); + }); + + it("replace updates the path WITHOUT notifying", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.replace("/default"); + expect(h.path).toBe("/default"); + expect(seen).toEqual([]); + }); + + it("fires subscribers on popstate (back/forward)", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + w.back("/my-ws"); + expect(seen).toEqual(["/my-ws"]); + }); + + it("unsubscribe stops notifications (navigate + popstate)", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + const unsub = h.subscribe((p) => seen.push(p)); + unsub(); + h.navigate("/default"); + w.back("/other"); + expect(seen).toEqual([]); + }); + + it("degrades to a no-op adapter when there is no location (SSR)", () => { + const w = { location: undefined } as unknown as HistoryWindow; + const h = createHistoryAdapter({ window: w }); + expect(h.path).toBe("/"); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.navigate("/default"); + expect(seen).toEqual([]); + }); +}); diff --git a/src/adapters/history/index.ts b/src/adapters/history/index.ts new file mode 100644 index 0000000..87deafb --- /dev/null +++ b/src/adapters/history/index.ts @@ -0,0 +1,93 @@ +/** + * History adapter — the injected browser effect for client-side routing. + * + * A thin wrapper over `window.history` + the `popstate` event that exposes the + * current pathname, a `navigate` (pushState) that updates the URL without a + * reload, and a `subscribe` for path changes (back/forward buttons + programmatic + * navigation). The route SEMANTICS (path → workspace/home) live in the + * `workspaces` feature's pure `parsePath`; this adapter deals only in path + * strings, so it stays generic + reusable. + * + * The browser edge (`window`/`history`) is INJECTED (defaults to the global) so + * it is testable without the DOM and degrades to a no-op when absent (SSR / no + * `window`). + */ + +/** The minimal browser-history surface this adapter needs. */ +export interface HistoryWindow { + readonly location: { readonly pathname: string }; + readonly history: { + pushState(data: unknown, unused: string, url?: string | URL | null): void; + replaceState(data: unknown, unused: string, url?: string | URL | null): void; + }; + addEventListener(type: "popstate", listener: () => void): void; + removeEventListener(type: "popstate", listener: () => void): void; +} + +export interface HistoryAdapter { + /** The current pathname. */ + readonly path: string; + /** Push a new path (updates the URL without a reload) + notifies subscribers. */ + navigate(path: string): void; + /** Replace the current path without adding a history entry (no notify). */ + replace(path: string): void; + /** Subscribe to path changes (back/forward + navigate). Returns unsubscribe. */ + subscribe(cb: (path: string) => void): () => void; +} + +export interface CreateHistoryOptions { + /** The browser window; defaults to `globalThis`. Inject for tests. */ + readonly window?: HistoryWindow; +} + +function noop(): void {} + +/** A no-op adapter for when there is no `window` (SSR). */ +function createNoopHistory(): HistoryAdapter { + return { + get path() { + return "/"; + }, + navigate: noop, + replace: noop, + subscribe() { + return noop; + }, + }; +} + +export function createHistoryAdapter(opts?: CreateHistoryOptions): HistoryAdapter { + const w = opts?.window ?? (globalThis as unknown as HistoryWindow); + if (w === undefined || w === null || w.location === undefined) { + return createNoopHistory(); + } + + const listeners = new Set<(path: string) => void>(); + const current = (): string => w.location.pathname; + const emit = (): void => { + const p = current(); + for (const cb of listeners) cb(p); + }; + + return { + get path() { + return current(); + }, + navigate(path: string): void { + w.history.pushState(null, "", path); + emit(); + }, + replace(path: string): void { + w.history.replaceState(null, "", path); + }, + subscribe(cb: (path: string) => void): () => void { + listeners.add(cb); + const onPop = (): void => cb(current()); + w.addEventListener("popstate", onPop); + return () => { + listeners.delete(cb); + w.removeEventListener("popstate", onPop); + }; + }, + }; +} diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts index 92d57a8..e8e5434 100644 --- a/src/adapters/ws/index.test.ts +++ b/src/adapters/ws/index.test.ts @@ -283,11 +283,18 @@ describe("createSurfaceSocket", () => { }); ws.resolveOpen(); - ws.invokeMessage(JSON.stringify({ type: "conversation.open", conversationId: "c1" })); + ws.invokeMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }), + ); expect(onConversationOpen).toHaveBeenCalledOnce(); expect(onConversationOpen).toHaveBeenCalledWith({ type: "conversation.open", conversationId: "c1", + workspaceId: "w1", }); expect(onMessage).not.toHaveBeenCalled(); expect(onChat).not.toHaveBeenCalled(); @@ -310,6 +317,7 @@ describe("createSurfaceSocket", () => { type: "conversation.statusChanged", conversationId: "c1", status: "active", + workspaceId: "w1", }), ); expect(onConversationStatusChanged).toHaveBeenCalledOnce(); @@ -317,6 +325,7 @@ describe("createSurfaceSocket", () => { type: "conversation.statusChanged", conversationId: "c1", status: "active", + workspaceId: "w1", }); expect(onMessage).not.toHaveBeenCalled(); }); diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index 2463519..062d47b 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -219,18 +219,52 @@ describe("parseServerMessage", () => { }); it("parses a conversation.open message", () => { - const data = JSON.stringify({ type: "conversation.open", conversationId: "c1" }); + const data = JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); const result = parseServerMessage(data); - expect(result).toEqual({ type: "conversation.open", conversationId: "c1" }); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); }); it("returns null for conversation.open with missing conversationId", () => { - expect(parseServerMessage(JSON.stringify({ type: "conversation.open" }))).toBeNull(); + expect( + parseServerMessage(JSON.stringify({ type: "conversation.open", workspaceId: "w1" })), + ).toBeNull(); }); it("returns null for conversation.open with non-string conversationId", () => { expect( - parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: 42 })), + parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: 42, + workspaceId: "w1", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.open with missing workspaceId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: "c1" })), + ).toBeNull(); + }); + + it("returns null for conversation.open with non-string workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: 42, + }), + ), ).toBeNull(); }); @@ -239,14 +273,40 @@ describe("parseServerMessage", () => { type: "conversation.statusChanged", conversationId: "c1", status: "active", + workspaceId: "w1", }); expect(parseServerMessage(data)).toEqual({ type: "conversation.statusChanged", conversationId: "c1", status: "active", + workspaceId: "w1", }); }); + it("returns null for conversation.statusChanged with missing workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.statusChanged with non-string workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: 42, + }), + ), + ).toBeNull(); + }); it("returns null for conversation.statusChanged with invalid status", () => { expect( parseServerMessage( @@ -254,6 +314,7 @@ describe("parseServerMessage", () => { type: "conversation.statusChanged", conversationId: "c1", status: "done", + workspaceId: "w1", }), ), ).toBeNull(); diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index b11c5c4..d1a5b5f 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -115,9 +115,11 @@ export function parseServerMessage(data: string): WsServerMessage | null { } case "conversation.open": { if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.workspaceId !== "string") return null; const msg: ConversationOpenMessage = { type: "conversation.open", conversationId: parsed.conversationId, + workspaceId: parsed.workspaceId, }; return msg; } @@ -127,10 +129,12 @@ export function parseServerMessage(data: string): WsServerMessage | null { if (parsed.status !== "active" && parsed.status !== "idle" && parsed.status !== "closed") { return null; } + if (typeof parsed.workspaceId !== "string") return null; const msg: ConversationStatusChangedMessage = { type: "conversation.statusChanged", conversationId: parsed.conversationId, status: parsed.status, + workspaceId: parsed.workspaceId, }; return msg; } |
