summaryrefslogtreecommitdiffhomepage
path: root/src/adapters
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 10:52:13 +0900
committerAdam Malczewski <[email protected]>2026-06-25 10:52:13 +0900
commit17ce47987e673b6618454d033885b17b2a01912e (patch)
tree2bd7259d51725eeadc20d410667d529f66834197 /src/adapters
parenteff289b2b4cc41db706f3c3274a089d46012be87 (diff)
downloaddispatch-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.ts98
-rw-r--r--src/adapters/history/index.ts93
-rw-r--r--src/adapters/ws/index.test.ts11
-rw-r--r--src/adapters/ws/logic.test.ts69
-rw-r--r--src/adapters/ws/logic.ts4
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;
}