From 4aed1996327d11daf4cd59a1848b77d03447e1ff Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 20 Oct 2026 10:11:15 -0400 Subject: [PATCH 01/45] Add maxReset option --- packages/client/src/api.ts | 12 +++++- packages/client/test/api.test.ts | 69 ++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 878bcbf5f..9e7f45eca 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -19,6 +19,10 @@ const STABLE_CONNECTION_THRESHOLD_MS = 30_000; // counting. const MAX_UNSTABLE_CONNECTION_COUNT = 15; +// The max number of times a request is retried after a retryable failure, +// unless overridden by `ApiRequestConfig.maxRetries`. +const DEFAULT_MAX_REQUEST_RETRIES = 3; + type ApiClientOptions = { host: string; apiKey: string; @@ -44,6 +48,8 @@ export type ApiRequestConfig = { data?: unknown; headers?: HeadersInit; signal?: AbortSignal; + /** Max number of retries for retryable failures. Set this to 0 to disable retries. */ + maxRetries?: number; }; type FetchClient = ( @@ -249,9 +255,11 @@ class ApiClient { private async requestWithRetries(req: ApiRequestConfig) { let lastError: unknown; + const { maxRetries = DEFAULT_MAX_REQUEST_RETRIES } = req; + // Sequential retry loop: each attempt awaits in order and returns early on // success or a non-retryable error, so it doesn't reduce to an array method. - for (let attempt = 0; attempt <= 3; attempt++) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { let retryAfterMs = 0; try { @@ -279,7 +287,7 @@ class ApiClient { } } - if (attempt < 3) { + if (attempt < maxRetries) { await this.delay(this.getRetryDelay(attempt + 1, retryAfterMs)); } } diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 757d48287..c01c87453 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -586,6 +586,75 @@ describe("API Client", () => { expect(response.statusCode).toBe("error"); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + test("does not retry when maxRetries is 0", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: undefined, + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + createJsonResponse({ error: "Server Error" }, 500), + ) + .mockResolvedValue(createJsonResponse({ success: true })); + setFetchMock(apiClient, fetchMock); + skipRetryDelays(apiClient); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => { }); + try { + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", + maxRetries: 0, + }); + + expect(response.statusCode).toBe("error"); + expect(response.status).toBe(500); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + consoleSpy.mockRestore(); + } + }); + + test("retries up to maxRetries times when overridden", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: undefined, + }); + // Return a fresh Response per call, like a real fetch — a Response body + // can only be consumed once, so each retry must get its own. + const fetchMock = vi + .fn() + .mockImplementation(() => + createJsonResponse({ error: "Server Error" }, 500), + ); + setFetchMock(apiClient, fetchMock); + skipRetryDelays(apiClient); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => { }); + try { + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", + maxRetries: 1, + }); + + expect(response.statusCode).toBe("error"); + expect(response.status).toBe(500); + + // The initial attempt plus one retry + expect(fetchMock).toHaveBeenCalledTimes(2); + } finally { + consoleSpy.mockRestore(); + } + }); }); describe("Request Configuration", () => { From 72182743947fb00c84dd460b23d6f3d9b3bffb7c Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 20 Oct 2026 10:22:07 -0400 Subject: [PATCH 02/45] Add changeset --- .changeset/ripe-pandas-eat.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/ripe-pandas-eat.md diff --git a/.changeset/ripe-pandas-eat.md b/.changeset/ripe-pandas-eat.md new file mode 100644 index 000000000..89363ed9f --- /dev/null +++ b/.changeset/ripe-pandas-eat.md @@ -0,0 +1,15 @@ +--- +"@knocklabs/client": minor +--- + +Add `maxRetries` option to `ApiRequestConfig` + +When making a request to the Knock API using `ApiClient`'s `makeRequest` function, the `maxRetries` option of `ApiRequestConfig` can now be used to set the maximum number of times the request should be retried when a retryable error occurs. To disable retries altogether, set `maxRetries` to 0. + +```js +const result = await knock.client().makeRequest({ + method: "GET", + url: `/v1/users`, + maxRetries: 0, +}); +``` From 319bf2eab332b936d0d4d1f864b264796dc15520 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 20 Oct 2026 11:07:48 -0400 Subject: [PATCH 03/45] Update changeset --- .changeset/ripe-pandas-eat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ripe-pandas-eat.md b/.changeset/ripe-pandas-eat.md index 89363ed9f..c22d566de 100644 --- a/.changeset/ripe-pandas-eat.md +++ b/.changeset/ripe-pandas-eat.md @@ -9,7 +9,7 @@ When making a request to the Knock API using `ApiClient`'s `makeRequest` functio ```js const result = await knock.client().makeRequest({ method: "GET", - url: `/v1/users`, + url: "/v1/users", maxRetries: 0, }); ``` From 5ced32cde75c02d8f7d7f7956cdbcda61991182f Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 13 Oct 2026 11:21:56 -0400 Subject: [PATCH 04/45] Add cacheUntilExpired implementation --- packages/client/src/helpers.ts | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/client/src/helpers.ts b/packages/client/src/helpers.ts index ea6329b95..f0c1c6e0e 100644 --- a/packages/client/src/helpers.ts +++ b/packages/client/src/helpers.ts @@ -43,3 +43,46 @@ export function exponentialBackoffFullJitter( const jitterRange = exponentialDelay - minDelayMs; return minDelayMs + Math.floor(Math.random() * jitterRange); } + +export type CachedFetch = { + /** + * Returns the cached value if still valid, otherwise calls `fetchFunc` with the same args and + * caches the result. + */ + fetch: (...args: Args) => Promise; + /** + * Drops any cached value so the next `fetch` call fetches again. + */ + invalidate: () => void; +}; + +export const CACHE_EXPIRY_PADDING_MS = 5_000; + +/** + * Wraps `fetchFunc` so its result is cached until `expiresAt` (epoch ms), minus + * `CACHE_EXPIRY_PADDING_MS`. + */ +export function cacheUntilExpired< + Args extends unknown[], + T extends { expiresAt: number }, +>(fetchFunc: (...args: Args) => Promise): CachedFetch { + let cached: T | null = null; + + const fetch = async (...args: Args) => { + if ( + cached && + cached.expiresAt - CACHE_EXPIRY_PADDING_MS > Date.now() + ) { + return cached; + } + const value = await fetchFunc(...args); + cached = value; + return value; + }; + + const invalidate = () => { + cached = null; + }; + + return { fetch, invalidate }; +} From ddc9babbcceea45781b881de083335073f0994ef Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 13 Oct 2026 11:26:41 -0400 Subject: [PATCH 05/45] Rename fetch to get --- packages/client/src/helpers.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/client/src/helpers.ts b/packages/client/src/helpers.ts index f0c1c6e0e..d1fccc46d 100644 --- a/packages/client/src/helpers.ts +++ b/packages/client/src/helpers.ts @@ -49,9 +49,9 @@ export type CachedFetch = { * Returns the cached value if still valid, otherwise calls `fetchFunc` with the same args and * caches the result. */ - fetch: (...args: Args) => Promise; + get: (...args: Args) => Promise; /** - * Drops any cached value so the next `fetch` call fetches again. + * Drops any cached value so the next `get` call fetches again. */ invalidate: () => void; }; @@ -68,11 +68,8 @@ export function cacheUntilExpired< >(fetchFunc: (...args: Args) => Promise): CachedFetch { let cached: T | null = null; - const fetch = async (...args: Args) => { - if ( - cached && - cached.expiresAt - CACHE_EXPIRY_PADDING_MS > Date.now() - ) { + const get = async (...args: Args) => { + if (cached && cached.expiresAt - CACHE_EXPIRY_PADDING_MS > Date.now()) { return cached; } const value = await fetchFunc(...args); @@ -84,5 +81,5 @@ export function cacheUntilExpired< cached = null; }; - return { fetch, invalidate }; + return { get, invalidate }; } From ea17afcedaf482b8f9afe6ff5f40c4eecf51ca58 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 13 Oct 2026 11:53:53 -0400 Subject: [PATCH 06/45] Add tests for cacheUntilExpired --- packages/client/test/helpers.test.ts | 127 ++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/client/test/helpers.test.ts b/packages/client/test/helpers.test.ts index 46bf918a2..0ef9f4596 100644 --- a/packages/client/test/helpers.test.ts +++ b/packages/client/test/helpers.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { exponentialBackoffFullJitter, isValidUuid } from "../src/helpers"; +import { + CACHE_EXPIRY_PADDING_MS, + cacheUntilExpired, + exponentialBackoffFullJitter, + isValidUuid, +} from "../src/helpers"; /** * Helper Functions Test Suite @@ -193,5 +198,123 @@ describe("Helper Functions", () => { }), ).toBe(250); }); - }) + }); + + describe("cacheUntilExpired", () => { + const TEST_ARG = "Hello, world!"; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("first get call invokes fetchFunc with the given argument", async () => { + const fetchFunc = vi.fn(async (testArg: string) => ({ + testArg, + expiresAt: 60_000, + })); + const { get } = cacheUntilExpired(fetchFunc); + + const result = await get(TEST_ARG); + + expect(fetchFunc).toHaveBeenCalledTimes(1); + expect(fetchFunc).toHaveBeenCalledWith(TEST_ARG); + expect(result).toEqual({ testArg: TEST_ARG, expiresAt: 60_000 }); + }); + + test("second call before expiry returns the cached value without refetching", async () => { + const fetchFunc = vi.fn(async (_testArg: string) => ({ + value: 1, + expiresAt: 60_000, + })); + const { get } = cacheUntilExpired(fetchFunc); + + const first = await get(TEST_ARG); + const second = await get(TEST_ARG); + + expect(fetchFunc).toHaveBeenCalledTimes(1); + expect(fetchFunc).toHaveBeenCalledWith(TEST_ARG); + expect(second).toBe(first); + }); + + test("refetches within the expiry padding window and caches the new value", async () => { + const fetchFunc = vi + .fn< + (testArg: string) => Promise<{ value: number; expiresAt: number }> + >() + .mockResolvedValueOnce({ value: 1, expiresAt: 60_000 }) + .mockResolvedValueOnce({ value: 2, expiresAt: 120_000 }); + const { get } = cacheUntilExpired(fetchFunc); + + await get(TEST_ARG); + vi.setSystemTime(60_000 - CACHE_EXPIRY_PADDING_MS); + const second = await get(TEST_ARG); + const third = await get(TEST_ARG); + + expect(fetchFunc).toHaveBeenCalledTimes(2); + expect(fetchFunc).toHaveBeenCalledWith(TEST_ARG); + expect(second).toEqual({ value: 2, expiresAt: 120_000 }); + expect(third).toBe(second); + }); + + test("refetches after expiry and caches the new value", async () => { + const fetchFunc = vi + .fn< + (testArg: string) => Promise<{ value: number; expiresAt: number }> + >() + .mockResolvedValueOnce({ value: 1, expiresAt: 60_000 }) + .mockResolvedValueOnce({ value: 2, expiresAt: 120_000 }); + const { get } = cacheUntilExpired(fetchFunc); + + await get(TEST_ARG); + vi.setSystemTime(60_001); + const second = await get(TEST_ARG); + const third = await get(TEST_ARG); + + expect(fetchFunc).toHaveBeenCalledTimes(2); + expect(fetchFunc).toHaveBeenCalledWith(TEST_ARG); + expect(second).toEqual({ value: 2, expiresAt: 120_000 }); + expect(third).toBe(second); + }); + + test("invalidate forces the next get call to refetch", async () => { + const fetchFunc = vi + .fn< + (testArg: string) => Promise<{ value: number; expiresAt: number }> + >() + .mockResolvedValueOnce({ value: 1, expiresAt: 60_000 }) + .mockResolvedValueOnce({ value: 2, expiresAt: 120_000 }); + const { get, invalidate } = cacheUntilExpired(fetchFunc); + + await get(TEST_ARG); + invalidate(); + const second = await get(TEST_ARG); + + expect(fetchFunc).toHaveBeenCalledTimes(2); + expect(fetchFunc).toHaveBeenCalledWith(TEST_ARG); + expect(second).toEqual({ value: 2, expiresAt: 120_000 }); + }); + + test("a rejected fetchFunc propagates and does not populate the cache", async () => { + const error = new Error("boom"); + const fetchFunc = vi + .fn< + (testArg: string) => Promise<{ value: number; expiresAt: number }> + >() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ value: 1, expiresAt: 60_000 }); + const { get } = cacheUntilExpired(fetchFunc); + + await expect(get(TEST_ARG)).rejects.toThrow(error); + const second = await get(TEST_ARG); + + expect(fetchFunc).toHaveBeenCalledTimes(2); + expect(fetchFunc).toHaveBeenCalledWith(TEST_ARG); + expect(second).toEqual({ value: 1, expiresAt: 60_000 }); + }); + }); }); From c60fc9d156af2c7a2f971d9ca30e4849ebd3cbcf Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 13 Oct 2026 13:46:51 -0400 Subject: [PATCH 07/45] Add types --- packages/client/src/gatewayTransport.ts | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/client/src/gatewayTransport.ts diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts new file mode 100644 index 000000000..d6ed345dd --- /dev/null +++ b/packages/client/src/gatewayTransport.ts @@ -0,0 +1,36 @@ +/** + * The signature that Phoenix expects socket transports to conform to, based on the native browser + * `WebSocket`. Only fields and methods that Phoenix relies on are included here. + */ +export interface WebSocketLike { + readonly readyState: number; + readonly bufferedAmount: number; + binaryType: BinaryType; + onopen: ((event: Event) => void) | null; + onclose: ((event: CloseEvent) => void) | null; + onerror: ((event: Event) => void) | null; + onmessage: ((event: MessageEvent) => void) | null; + send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void; + close(code?: number, reason?: string): void; +} + +export type TransportConstructor = new (url: string, protocols?: string | string[]) => WebSocketLike; + +export type GatewayConnectResult = { + signedUrl: string; + expiresAt: number; +}; + +export type GatewayTransportOptions = { + /** + * Resolves the signed WebSocket URL to connect to. Rejects on failure. + */ + fetchConnectUrl: (signal: AbortSignal) => Promise; + /** + * Called when a connection made with the fetched URL closed before it ever + * opened, which most likely means the URL was rejected (though a network blip + * looks the same). + */ + onConnectUrlInvalid?: () => void; +}; + From d7a5b74e75e8e444d7d0939099acff6a90745e75 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 14 Oct 2026 09:58:09 -0400 Subject: [PATCH 08/45] Add getQueryString util function --- packages/client/src/helpers.ts | 10 ++++++++++ packages/client/test/helpers.test.ts | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/client/src/helpers.ts b/packages/client/src/helpers.ts index d1fccc46d..276e9b569 100644 --- a/packages/client/src/helpers.ts +++ b/packages/client/src/helpers.ts @@ -5,6 +5,16 @@ export function isValidUuid(uuid: string) { return uuidRegex.test(uuid); } +/** + * Returns the query string for a given URL, minus the leading `"?"` character. When the URL has no + * query string, returns the empty string. + * @param url a valid string URL. For example, `"https://example.com/test?hello=world"`. + */ +export function getQueryString(url: string): string { + const idx = url.indexOf("?"); + return idx === -1 ? "" : url.slice(idx + 1); +} + /** * Exponential backoff with full jitter and a minimum delay floor. * diff --git a/packages/client/test/helpers.test.ts b/packages/client/test/helpers.test.ts index 0ef9f4596..644d01a20 100644 --- a/packages/client/test/helpers.test.ts +++ b/packages/client/test/helpers.test.ts @@ -4,6 +4,7 @@ import { CACHE_EXPIRY_PADDING_MS, cacheUntilExpired, exponentialBackoffFullJitter, + getQueryString, isValidUuid, } from "../src/helpers"; @@ -119,6 +120,22 @@ describe("Helper Functions", () => { }); }); + describe("getQueryString", () => { + test("returns everything after the first '?'", () => { + expect(getQueryString("https://example.com/test?hello=world")).toBe( + "hello=world", + ); + }); + + test("returns the empty string when there is no '?'", () => { + expect(getQueryString("https://example.com/test")).toBe(""); + }); + + test("returns the empty string when the '?' is the last character", () => { + expect(getQueryString("https://example.com/test?")).toBe(""); + }); + }); + describe("exponentialBackoffFullJitter", () => { beforeEach(() => { vi.spyOn(Math, "random"); From efdfb0e6e6ae5b5c97291cba7b3841bd0167de98 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 14 Oct 2026 10:12:01 -0400 Subject: [PATCH 09/45] Add appendQueryString --- packages/client/src/helpers.ts | 11 ++++++++++ packages/client/test/helpers.test.ts | 33 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/packages/client/src/helpers.ts b/packages/client/src/helpers.ts index 276e9b569..38e437c82 100644 --- a/packages/client/src/helpers.ts +++ b/packages/client/src/helpers.ts @@ -15,6 +15,17 @@ export function getQueryString(url: string): string { return idx === -1 ? "" : url.slice(idx + 1); } +/** + * Appends a query string to a given URL. Note that this does not dedupe query params. + * @param url a valid string URL. + * @param queryString a query string, minus any leading `"?"` character. + */ +export function appendQueryString(url: string, queryString: string): string { + return queryString + ? `${url}${url.includes("?") ? "&" : "?"}${queryString}` + : url; +} + /** * Exponential backoff with full jitter and a minimum delay floor. * diff --git a/packages/client/test/helpers.test.ts b/packages/client/test/helpers.test.ts index 644d01a20..7e000666c 100644 --- a/packages/client/test/helpers.test.ts +++ b/packages/client/test/helpers.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { + appendQueryString, CACHE_EXPIRY_PADDING_MS, cacheUntilExpired, exponentialBackoffFullJitter, @@ -136,6 +137,38 @@ describe("Helper Functions", () => { }); }); + describe("appendQueryString", () => { + test("appends the query string to a URL with no existing query string", () => { + expect(appendQueryString("https://example.com/test", "a=1")).toBe( + "https://example.com/test?a=1", + ); + }); + + test("appends the query string with '&' when the URL already has one", () => { + expect( + appendQueryString("https://example.com/test?a=1", "b=2"), + ).toBe("https://example.com/test?a=1&b=2"); + }); + + test("returns the URL unchanged when the query string is empty", () => { + expect(appendQueryString("https://example.com/test", "")).toBe( + "https://example.com/test", + ); + }); + + test("returns the URL unchanged when the query string is empty, even if the URL already has one", () => { + expect(appendQueryString("https://example.com/test?a=1", "")).toBe( + "https://example.com/test?a=1", + ); + }); + + test("does not dedupe keys shared between the URL and the query string", () => { + expect( + appendQueryString("https://example.com/test?a=1", "a=2"), + ).toBe("https://example.com/test?a=1&a=2"); + }); + }); + describe("exponentialBackoffFullJitter", () => { beforeEach(() => { vi.spyOn(Math, "random"); From a09bc23accdb12e506a16082d43e28e7bae6e80f Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 14 Oct 2026 11:15:58 -0400 Subject: [PATCH 10/45] Add GatewayTransport --- packages/client/src/gatewayTransport.ts | 168 +++++++++++++++++++++++- 1 file changed, 166 insertions(+), 2 deletions(-) diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index d6ed345dd..cc0398b97 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -1,3 +1,5 @@ +import { appendQueryString, getQueryString } from "./helpers"; + /** * The signature that Phoenix expects socket transports to conform to, based on the native browser * `WebSocket`. Only fields and methods that Phoenix relies on are included here. @@ -8,8 +10,8 @@ export interface WebSocketLike { binaryType: BinaryType; onopen: ((event: Event) => void) | null; onclose: ((event: CloseEvent) => void) | null; - onerror: ((event: Event) => void) | null; onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: Event) => void) | null; send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void; close(code?: number, reason?: string): void; } @@ -29,8 +31,170 @@ export type GatewayTransportOptions = { /** * Called when a connection made with the fetched URL closed before it ever * opened, which most likely means the URL was rejected (though a network blip - * looks the same). + * could look the same). */ onConnectUrlInvalid?: () => void; }; +/** + * All possible socket states. Copied from Phoenix, based on the `readyState` property of + * `WebSocket`. + * @see {@link https://github.com/phoenixframework/phoenix/blob/1bae3a639f84cd97ccb6b984a8ef4fcdc7aa48ae/assets/js/phoenix/constants.js#L5} + */ +const SOCKET_STATES = { connecting: 0, open: 1, closing: 2, closed: 3 } as const; + +// WebSocket close event status codes. +// See https://www.rfc-editor.org/info/rfc6455/#section-7.4.1 +const CLOSE_CODE_NORMAL = 1000; +const CLOSE_CODE_ABNORMAL = 1006; + +export function createGatewayTransport( + baseTransport: TransportConstructor, + { fetchConnectUrl, onConnectUrlInvalid }: GatewayTransportOptions, +): TransportConstructor { + return class GatewayTransport implements WebSocketLike { + binaryType: BinaryType = "blob"; + timeout?: number; + onopen: ((event: Event) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + + private inner: WebSocketLike | null = null; + private state: number = SOCKET_STATES.connecting; + private readonly abort = new AbortController(); + private readonly unsignedQueryString: string; + private opened = false; + private closeRequested = false; + private settled = false; + + constructor( + url: string, + private readonly protocols?: string | string[], + ) { + // The base URL passed in by Phoenix contains some query params we want to preserve, including + // vsn (protocol version to send on connect), api_key, user_token, and branch_slug. This query + // string is considered "unsigned" because it lacks an auth_token query param. + this.unsignedQueryString = getQueryString(url); + + // Wait for the caller to finish setting up callbacks and properties. This pattern was copied + // from Phoenix's LongPoll transport. + setTimeout(() => this.start(), 0); + } + + get readyState(): number { + return this.inner ? this.inner.readyState : this.state; + } + + get bufferedAmount(): number { + return this.inner ? this.inner.bufferedAmount : 0; + } + + send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void { + if (!this.inner) { + // Phoenix only sends when a connection is open, so prevent calls to send before a + // connection is established. + throw new Error("GatewayTransport: cannot send before the connection is open"); + } + this.inner.send(data); + } + + close(code?: number, reason?: string): void { + if (this.closeRequested || this.settled) { + return; + } + + this.closeRequested = true; + + if (this.inner) { + this.inner.close(code, reason); + return; + } + + // The socket is closing before the fetch completed. This is still considered a clean close. + this.abort.abort(); + this.state = SOCKET_STATES.closed; + this.emitClose(CLOSE_CODE_NORMAL, "", true); + } + + private async start(): Promise { + // Phoenix may connect() and disconnect() in the same tick; teardown then calls close() on us + // before this runs. This prevents a fetch that would be discarded. + if (this.closeRequested) { + return; + } + + let result: GatewayConnectResult; + try { + result = await fetchConnectUrl(this.abort.signal); + } catch (error) { + if (this.closeRequested) { + // Ignore this error; the socket was already closed cleanly. + return; + } + // An unexepected error occurred while fetching + this.fail(error); + return; + } + + // The socket was closed while a fetch was in-progress + if (this.closeRequested) { + return; + } + + const connectUrl = appendQueryString(result.signedUrl, this.unsignedQueryString) + + try { + this.inner = this.initBaseTransport(connectUrl); + } catch (error) { + this.fail(error); + } + } + + private initBaseTransport(url: string): WebSocketLike { + const inner = new baseTransport(url, this.protocols); + + inner.binaryType = this.binaryType; + inner.onopen = (ev) => { + this.opened = true; + this.onopen?.(ev); + }; + inner.onmessage = (ev) => this.onmessage?.(ev); + inner.onerror = (ev) => this.onerror?.(ev); + inner.onclose = (ev) => { + if (!this.opened && !this.closeRequested) { + // A connection was never opened and this close event was unexpected. + // The signed connect URL is likely invalid. + onConnectUrlInvalid?.(); + } + this.settled = true; + this.onclose?.(ev); + }; + + return inner; + } + + private fail(error: unknown): void { + this.state = SOCKET_STATES.closed; + this.onerror?.(error); + this.emitClose(CLOSE_CODE_ABNORMAL, "gateway_connect_failed", false); + } + + private emitClose(code: number, reason: string, wasClean: boolean): void { + if (this.settled) { + return; + } + + this.settled = true; + + // Mirrors Phoenix's LongPoll.close: CloseEvent exists in browsers. + // Fall back to a plain object elsewhere (e.g. React Native). + const opts = { code, reason, wasClean }; + if (typeof CloseEvent !== "undefined") { + this.onclose?.(new CloseEvent("close", opts)); + } else { + this.onclose?.(opts as CloseEvent); + } + } + }; +} From 16509b1f5ed5284551116190f6734ed9114b5bf2 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 14 Oct 2026 11:37:05 -0400 Subject: [PATCH 11/45] Export socket states const --- packages/client/src/gatewayTransport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index cc0398b97..48339ce6b 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -41,7 +41,7 @@ export type GatewayTransportOptions = { * `WebSocket`. * @see {@link https://github.com/phoenixframework/phoenix/blob/1bae3a639f84cd97ccb6b984a8ef4fcdc7aa48ae/assets/js/phoenix/constants.js#L5} */ -const SOCKET_STATES = { connecting: 0, open: 1, closing: 2, closed: 3 } as const; +export const SOCKET_STATES = { connecting: 0, open: 1, closing: 2, closed: 3 } as const; // WebSocket close event status codes. // See https://www.rfc-editor.org/info/rfc6455/#section-7.4.1 From 300e97ed348da9447328e36040f5d21a26fd3d00 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Thu, 15 Oct 2026 09:40:42 -0400 Subject: [PATCH 12/45] Add comment --- packages/client/src/gatewayTransport.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index 48339ce6b..6d2e8b593 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -52,6 +52,7 @@ export function createGatewayTransport( baseTransport: TransportConstructor, { fetchConnectUrl, onConnectUrlInvalid }: GatewayTransportOptions, ): TransportConstructor { + // Use a named class, since Phoenix logs using the class name return class GatewayTransport implements WebSocketLike { binaryType: BinaryType = "blob"; timeout?: number; From 96be4234da33f8f183246cb08a4a008e7af66f77 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Fri, 16 Oct 2026 14:15:47 -0400 Subject: [PATCH 13/45] Add tests --- packages/client/test/gatewayTransport.test.ts | 380 ++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 packages/client/test/gatewayTransport.test.ts diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts new file mode 100644 index 000000000..9d66b0b57 --- /dev/null +++ b/packages/client/test/gatewayTransport.test.ts @@ -0,0 +1,380 @@ +import { + afterEach, + assert, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; + +import { + GatewayConnectResult, + SOCKET_STATES, + TransportConstructor, + WebSocketLike, + createGatewayTransport, +} from "../src/gatewayTransport"; + +class MockTransport implements WebSocketLike { + static instance: MockTransport | null = null; + + // GatewayTransport should only construct a single instance of the base transport, so use a static + // integer to keep track of how many times MockTransport's constructor was called + static constructionCount = 0; + + readyState: number = SOCKET_STATES.connecting; + bufferedAmount = 0; + binaryType: BinaryType = "blob"; + + onopen: ((event: Event) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + send = vi.fn(); + + close = vi.fn((code, reason) => { + this.readyState = SOCKET_STATES.closed; + this.onclose?.({ + code: code ?? 1000, + reason: reason ?? "", + wasClean: true, + } as CloseEvent); + }); + + constructor( + public url: string, + public protocols?: string | string[], + ) { + MockTransport.constructionCount++; + if (MockTransport.instance) { + assert.fail("MockTransport: constructed more than once"); + } + MockTransport.instance = this; + } + + open() { + this.readyState = SOCKET_STATES.open; + this.onopen?.({} as Event); + } + + serverClose(code: number, wasClean: boolean) { + this.readyState = SOCKET_STATES.closed; + this.onclose?.({ code, reason: "", wasClean } as CloseEvent); + } +} + +// GatewayTransport's start function is called inside a setTimeout within the constructor. This +// function advances timers so that start is invoked. +const flushStart = () => vi.advanceTimersByTimeAsync(0); + +const PHOENIX_URL = + "wss://api.knock.app/ws/v1/websocket?user_token=ut&api_key=pk&branch_slug=null&vsn=2.0.0"; +const SIGNED_URL = "wss://ws1.knock-dev.app/ws/v1/websocket?auth_token=jwt"; +const MERGED_URL = `${SIGNED_URL}&user_token=ut&api_key=pk&branch_slug=null&vsn=2.0.0`; +const PROTOCOLS = ["soap", "wamp"]; + +describe("createGatewayTransport", () => { + const fetchConnectUrl = vi.fn< + (signal: AbortSignal) => Promise + >(async () => ({ + signedUrl: SIGNED_URL, + expiresAt: Date.now() + 60_000, + })); + const onConnectUrlInvalid = vi.fn<() => void>(); + let GatewayTransport: TransportConstructor; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + + MockTransport.instance = null; + MockTransport.constructionCount = 0; + + GatewayTransport = createGatewayTransport(MockTransport, { + fetchConnectUrl, + onConnectUrlInvalid, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("fetches the connect URL, then constructs the base transport with merged URL and protocols", async () => { + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + + expect(fetchConnectUrl).not.toHaveBeenCalled(); + expect(MockTransport.constructionCount).toBe(0); + expect(gatewayTransport.readyState).toBe(SOCKET_STATES.connecting); + expect(gatewayTransport.bufferedAmount).toBe(0); + + await flushStart(); + + expect(fetchConnectUrl).toHaveBeenCalledWith(expect.any(AbortSignal)); + const inner = getMockTransportInstance(); + expect(inner.url).toBe(MERGED_URL); + expect(inner.protocols).toBe(PROTOCOLS); + expect(gatewayTransport.readyState).toBe(inner.readyState); + expect(gatewayTransport.bufferedAmount).toBe(0); + }); + + test("applies a binaryType assigned after construction to the inner transport", async () => { + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + + expect(gatewayTransport.binaryType).toBe("blob"); + expect(MockTransport.constructionCount).toBe(0); + + gatewayTransport.binaryType = "arraybuffer"; + + await flushStart(); + + expect(getMockTransportInstance().binaryType).toBe("arraybuffer"); + }); + + test("forwards events from the inner transport and delegates readyState/bufferedAmount", async () => { + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + await flushStart(); + const inner = getMockTransportInstance(); + + const onopen = vi.fn(); + const onmessage = vi.fn(); + const onerror = vi.fn(); + const onclose = vi.fn(); + gatewayTransport.onopen = onopen; + gatewayTransport.onmessage = onmessage; + gatewayTransport.onerror = onerror; + gatewayTransport.onclose = onclose; + + inner.open(); + expect(onopen).toHaveBeenCalledTimes(1); + + expect(gatewayTransport.readyState).toBe(SOCKET_STATES.open); + + const messageEvent = { data: "x" } as MessageEvent; + inner.onmessage?.(messageEvent); + expect(onmessage).toHaveBeenCalledWith(messageEvent); + + const error = new Error("boom"); + inner.onerror?.(error as unknown as Event); + expect(onerror).toHaveBeenCalledWith(error); + + const bufferedAmount = 128; + inner.bufferedAmount = bufferedAmount; + expect(gatewayTransport.bufferedAmount).toBe(bufferedAmount); + + const closeEvent = { code: 1000, reason: "", wasClean: true } as CloseEvent; + inner.onclose?.(closeEvent); + expect(onclose).toHaveBeenCalledWith(closeEvent); + }); + + test("send throws before the inner transport exists and delegates once connected", async () => { + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + + expect(() => gatewayTransport.send("hi")).toThrow( + "GatewayTransport: cannot send before the connection is open", + ); + + await flushStart(); + + const inner = getMockTransportInstance(); + gatewayTransport.send("hi"); + + expect(inner.send).toHaveBeenCalledWith("hi"); + }); + + test("close() while the fetch is pending aborts it and emits a single clean close", async () => { + fetchConnectUrl.mockImplementationOnce( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve({ + signedUrl: SIGNED_URL, + expiresAt: Date.now() + 60_000, + }), + 1000, + ); + }), + ); + + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + await flushStart(); + + const onclose = vi.fn(); + gatewayTransport.onclose = onclose; + + gatewayTransport.close(); + + const capturedSignal = fetchConnectUrl.mock.lastCall?.[0]; + + expect(capturedSignal?.aborted).toBe(true); + expect(gatewayTransport.readyState).toBe(SOCKET_STATES.closed); + expect(onclose).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 1000, reason: "", wasClean: true }), + ); + + // Advance test timer so that fetchConnectUrl resolves + await vi.advanceTimersByTimeAsync(1000); + + // No inner transport was instantiated, as the fetch never completed + expect(MockTransport.constructionCount).toBe(0); + expect(onclose).toHaveBeenCalledTimes(1); + + // Closing the gateway transport manually does not trigger another onclose event + gatewayTransport.close(); + expect(onclose).toHaveBeenCalledTimes(1); + }); + + test("close() called immediately after construction emits a clean close without fetching", async () => { + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + + const onclose = vi.fn(); + gatewayTransport.onclose = onclose; + + gatewayTransport.close(); + + expect(gatewayTransport.readyState).toBe(SOCKET_STATES.closed); + expect(onclose).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 1000, reason: "", wasClean: true }), + ); + + await flushStart(); + + expect(fetchConnectUrl).not.toHaveBeenCalled(); + + // No inner transport was instantiated, as the fetch never occurred + expect(MockTransport.constructionCount).toBe(0); + expect(onclose).toHaveBeenCalledTimes(1); + }); + + test("close(code, reason) after connecting delegates the same arguments to the inner transport", async () => { + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + await flushStart(); + const inner = getMockTransportInstance(); + inner.open(); + + gatewayTransport.close(1000, "bye"); + + expect(inner.close).toHaveBeenCalledWith(1000, "bye"); + }); + + test("fetchConnectUrl rejecting fails the connection without creating an inner transport", async () => { + const error = new Error("network down"); + fetchConnectUrl.mockRejectedValueOnce(error); + + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + const onerror = vi.fn(); + const onclose = vi.fn(); + gatewayTransport.onerror = onerror; + gatewayTransport.onclose = onclose; + + await flushStart(); + + expect(onerror).toHaveBeenCalledWith(error); + expect(onclose).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + code: 1006, + reason: "gateway_connect_failed", + wasClean: false, + }), + ); + expect(gatewayTransport.readyState).toBe(SOCKET_STATES.closed); + + // No inner transport was instantiated, as the fetch failed + expect(MockTransport.constructionCount).toBe(0); + }); + + test("connection fails when the base transport cannot be instantiated", async () => { + const error = new Error("Failed to instantiate base transport"); + class ThrowingTransport { + constructor() { + throw error; + } + } + const ThrowingGatewayTransport = createGatewayTransport( + ThrowingTransport as unknown as TransportConstructor, + { fetchConnectUrl, onConnectUrlInvalid }, + ); + + const transport = new ThrowingGatewayTransport(PHOENIX_URL, PROTOCOLS); + const onerror = vi.fn(); + const onclose = vi.fn(); + transport.onerror = onerror; + transport.onclose = onclose; + + await flushStart(); + + expect(onerror).toHaveBeenCalledWith(error); + expect(onclose).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + code: 1006, + reason: "gateway_connect_failed", + wasClean: false, + }), + ); + expect(transport.readyState).toBe(SOCKET_STATES.closed); + }); + + test("onConnectUrlInvalid fires when the inner transport closes before ever opening", async () => { + new GatewayTransport(PHOENIX_URL, PROTOCOLS); + await flushStart(); + const inner = getMockTransportInstance(); + + inner.serverClose(1006, false); + + expect(onConnectUrlInvalid).toHaveBeenCalledTimes(1); + }); + + test("onConnectUrlInvalid does not fire once the inner transport has opened", async () => { + new GatewayTransport(PHOENIX_URL, PROTOCOLS); + await flushStart(); + const inner = getMockTransportInstance(); + + inner.open(); + inner.serverClose(1006, false); + + expect(onConnectUrlInvalid).not.toHaveBeenCalled(); + }); + + test("onConnectUrlInvalid does not fire for a close that Phoenix itself requested", async () => { + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + await flushStart(); + const inner = getMockTransportInstance(); + + // Simulate Phoenix closing the socket while the inner transport is still + // connecting: the inner's own close() fires an unclean close event. + inner.close = vi.fn((code, reason) => { + inner.readyState = SOCKET_STATES.closed; + inner.onclose?.({ + code: code ?? 1006, + reason: reason ?? "", + wasClean: false, + } as CloseEvent); + }); + + gatewayTransport.close(); + + expect(onConnectUrlInvalid).not.toHaveBeenCalled(); + }); + + test("omitting onConnectUrlInvalid does not throw when the url is invalid", async () => { + const NoCallbackTransport = createGatewayTransport(MockTransport, { fetchConnectUrl }); + new NoCallbackTransport(PHOENIX_URL, PROTOCOLS); + await flushStart(); + const inner = getMockTransportInstance(); + + expect(() => inner.serverClose(1006, false)).not.toThrow(); + }); + + test("the returned class is named GatewayTransport", () => { + expect(GatewayTransport.name).toBe("GatewayTransport"); + }); + + function getMockTransportInstance(): MockTransport { + expect(MockTransport.constructionCount).toBe(1); + expect(MockTransport.instance).not.toBeNull(); + return MockTransport.instance!; + } +}); From a66851d85880df4188a5d58fb3e90a32128b1ca7 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Fri, 16 Oct 2026 14:25:53 -0400 Subject: [PATCH 14/45] Update tests --- packages/client/test/gatewayTransport.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts index 9d66b0b57..9a7d646e6 100644 --- a/packages/client/test/gatewayTransport.test.ts +++ b/packages/client/test/gatewayTransport.test.ts @@ -345,7 +345,7 @@ describe("createGatewayTransport", () => { // Simulate Phoenix closing the socket while the inner transport is still // connecting: the inner's own close() fires an unclean close event. - inner.close = vi.fn((code, reason) => { + inner.close.mockImplementation((code, reason) => { inner.readyState = SOCKET_STATES.closed; inner.onclose?.({ code: code ?? 1006, @@ -360,7 +360,9 @@ describe("createGatewayTransport", () => { }); test("omitting onConnectUrlInvalid does not throw when the url is invalid", async () => { - const NoCallbackTransport = createGatewayTransport(MockTransport, { fetchConnectUrl }); + const NoCallbackTransport = createGatewayTransport(MockTransport, { + fetchConnectUrl, + }); new NoCallbackTransport(PHOENIX_URL, PROTOCOLS); await flushStart(); const inner = getMockTransportInstance(); From fe4f5d7ea5af238ff3afd818016f5365314558dc Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Fri, 16 Oct 2026 14:59:05 -0400 Subject: [PATCH 15/45] Update tests --- packages/client/test/gatewayTransport.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts index 9a7d646e6..0c8cc0904 100644 --- a/packages/client/test/gatewayTransport.test.ts +++ b/packages/client/test/gatewayTransport.test.ts @@ -227,6 +227,39 @@ describe("createGatewayTransport", () => { expect(onclose).toHaveBeenCalledTimes(1); }); + test("close() while the fetch is pending ignores a rejection caused by the abort", async () => { + fetchConnectUrl.mockImplementationOnce( + (signal) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ); + }), + ); + + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + const onerror = vi.fn(); + const onclose = vi.fn(); + gatewayTransport.onerror = onerror; + gatewayTransport.onclose = onclose; + + await flushStart(); + + gatewayTransport.close(); + + // Run timers to trigger the rejection + await vi.runAllTimersAsync(); + + expect(onerror).not.toHaveBeenCalled(); + expect(onclose).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 1000, reason: "", wasClean: true }), + ); + expect(gatewayTransport.readyState).toBe(SOCKET_STATES.closed); + + // No inner transport was instantiated, as the fetch was aborted + expect(MockTransport.constructionCount).toBe(0); + }); + test("close() called immediately after construction emits a clean close without fetching", async () => { const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); From 31ae107c0f0062186484f3b83a4febcf430a44cb Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Fri, 16 Oct 2026 15:02:23 -0400 Subject: [PATCH 16/45] Update tests --- packages/client/test/gatewayTransport.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts index 0c8cc0904..90fa57dfc 100644 --- a/packages/client/test/gatewayTransport.test.ts +++ b/packages/client/test/gatewayTransport.test.ts @@ -293,6 +293,43 @@ describe("createGatewayTransport", () => { expect(inner.close).toHaveBeenCalledWith(1000, "bye"); }); + test("close() called again before the inner transport's close event fires is a no-op", async () => { + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + await flushStart(); + const inner = getMockTransportInstance(); + inner.open(); + + const onclose = vi.fn(); + gatewayTransport.onclose = onclose; + + // Like a native WebSocket, emit the close event asynchronously after close() is called + inner.close.mockImplementation((code, reason) => { + inner.readyState = SOCKET_STATES.closing; + setTimeout(() => { + inner.readyState = SOCKET_STATES.closed; + inner.onclose?.({ + code: code ?? 1000, + reason: reason ?? "", + wasClean: true, + } as CloseEvent); + }, 0); + }); + + gatewayTransport.close(1000, "bye"); + gatewayTransport.close(1000, "bye again"); + + expect(inner.close).toHaveBeenCalledExactlyOnceWith(1000, "bye"); + expect(gatewayTransport.readyState).toBe(SOCKET_STATES.closing); + expect(onclose).not.toHaveBeenCalled(); + + await vi.runAllTimersAsync(); + + expect(onclose).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 1000, reason: "bye", wasClean: true }), + ); + expect(gatewayTransport.readyState).toBe(SOCKET_STATES.closed); + }); + test("fetchConnectUrl rejecting fails the connection without creating an inner transport", async () => { const error = new Error("network down"); fetchConnectUrl.mockRejectedValueOnce(error); From ce44f5ce3223a16a344866f84b254c4183416678 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Fri, 16 Oct 2026 15:07:58 -0400 Subject: [PATCH 17/45] Update tests --- packages/client/test/gatewayTransport.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts index 90fa57dfc..1dcf0a3c5 100644 --- a/packages/client/test/gatewayTransport.test.ts +++ b/packages/client/test/gatewayTransport.test.ts @@ -354,6 +354,10 @@ describe("createGatewayTransport", () => { // No inner transport was instantiated, as the fetch failed expect(MockTransport.constructionCount).toBe(0); + + // Closing the failed gateway transport does not trigger another onclose event + gatewayTransport.close(); + expect(onclose).toHaveBeenCalledTimes(1); }); test("connection fails when the base transport cannot be instantiated", async () => { @@ -385,6 +389,29 @@ describe("createGatewayTransport", () => { }), ); expect(transport.readyState).toBe(SOCKET_STATES.closed); + + // Closing the failed gateway transport does not trigger another onclose event + transport.close(); + expect(onclose).toHaveBeenCalledTimes(1); + }); + + test("close() after the inner transport has closed on its own is a no-op", async () => { + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + await flushStart(); + const inner = getMockTransportInstance(); + + const onclose = vi.fn(); + gatewayTransport.onclose = onclose; + + inner.open(); + inner.serverClose(1006, false); + + expect(onclose).toHaveBeenCalledTimes(1); + + gatewayTransport.close(); + + expect(inner.close).not.toHaveBeenCalled(); + expect(onclose).toHaveBeenCalledTimes(1); }); test("onConnectUrlInvalid fires when the inner transport closes before ever opening", async () => { From 020da48f9f615d2a3f02fad9ea5ccfaedd141897 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Fri, 16 Oct 2026 15:25:14 -0400 Subject: [PATCH 18/45] Remove unnecessary timeout field --- packages/client/src/gatewayTransport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index 6d2e8b593..e421b34af 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -55,7 +55,7 @@ export function createGatewayTransport( // Use a named class, since Phoenix logs using the class name return class GatewayTransport implements WebSocketLike { binaryType: BinaryType = "blob"; - timeout?: number; + onopen: ((event: Event) => void) | null = null; onclose: ((event: CloseEvent) => void) | null = null; onmessage: ((event: MessageEvent) => void) | null = null; From 9e685d3abb7191efc99f1dd586ec1d9fcbddd134 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 11:06:08 -0400 Subject: [PATCH 19/45] Format code --- packages/client/src/gatewayTransport.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index e421b34af..959e5cc2f 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -16,7 +16,10 @@ export interface WebSocketLike { close(code?: number, reason?: string): void; } -export type TransportConstructor = new (url: string, protocols?: string | string[]) => WebSocketLike; +export type TransportConstructor = new ( + url: string, + protocols?: string | string[], +) => WebSocketLike; export type GatewayConnectResult = { signedUrl: string; @@ -41,7 +44,12 @@ export type GatewayTransportOptions = { * `WebSocket`. * @see {@link https://github.com/phoenixframework/phoenix/blob/1bae3a639f84cd97ccb6b984a8ef4fcdc7aa48ae/assets/js/phoenix/constants.js#L5} */ -export const SOCKET_STATES = { connecting: 0, open: 1, closing: 2, closed: 3 } as const; +export const SOCKET_STATES = { + connecting: 0, + open: 1, + closing: 2, + closed: 3, +} as const; // WebSocket close event status codes. // See https://www.rfc-editor.org/info/rfc6455/#section-7.4.1 @@ -95,7 +103,9 @@ export function createGatewayTransport( if (!this.inner) { // Phoenix only sends when a connection is open, so prevent calls to send before a // connection is established. - throw new Error("GatewayTransport: cannot send before the connection is open"); + throw new Error( + "GatewayTransport: cannot send before the connection is open", + ); } this.inner.send(data); } @@ -143,7 +153,10 @@ export function createGatewayTransport( return; } - const connectUrl = appendQueryString(result.signedUrl, this.unsignedQueryString) + const connectUrl = appendQueryString( + result.signedUrl, + this.unsignedQueryString, + ); try { this.inner = this.initBaseTransport(connectUrl); From e6fb1e7cf57fd6a97605d26b9aa694088aa9a887 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 11:10:34 -0400 Subject: [PATCH 20/45] Update ApiClient --- packages/client/src/api.ts | 55 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 9e7f45eca..e14108d27 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -1,6 +1,10 @@ import { Socket } from "phoenix"; -import { exponentialBackoffFullJitter } from "./helpers"; +import { + GatewayConnectResult, + createGatewayTransport, +} from "./gatewayTransport"; +import { cacheUntilExpired, exponentialBackoffFullJitter } from "./helpers"; import { PageVisibilityManager } from "./pageVisibility"; // Ceiling for socket reconnect backoff. This is intentionally much larger than @@ -123,7 +127,18 @@ class ApiClient { }); if (typeof window !== "undefined" && typeof WebSocket !== "undefined") { + // Connect sockets via the WebSocket gateway API endpoint, which returns a signed ws:// or + // wss:// connect URL. This signed URL is cached and used to reconnect until it expires. + const connectUrlCache = cacheUntilExpired((signal: AbortSignal) => + this.fetchSocketConnectUrl(signal), + ); + const transport = createGatewayTransport(WebSocket, { + fetchConnectUrl: connectUrlCache.get, + onConnectUrlInvalid: connectUrlCache.invalidate, + }); + this.socket = new Socket(`${this.host.replace("http", "ws")}/ws/v1`, { + transport: transport, params: { user_token: this.userToken, api_key: this.apiKey, @@ -240,7 +255,11 @@ class ApiClient { status: result.status, }; } catch (e: unknown) { - console.error(e); + // Aborted requests are deliberate (e.g. the socket transport cancelling a gateway fetch on + // disconnect), so we should skip logging them + if (!req.signal?.aborted) { + console.error(e); + } const response = (e as ErrorWithResponse)?.response; return { @@ -252,6 +271,38 @@ class ApiClient { } } + // Requests a signed WebSocket URL from the WebSocket gateway API endpoint. Rejects on any HTTP + // error or malformed body so the transport treats it like a failed connection attempt. + private async fetchSocketConnectUrl( + signal: AbortSignal, + ): Promise { + const result = await this.makeRequest({ + method: "POST", + url: "/v1/sockets/connect", + signal, + }); + + if (result.statusCode !== "ok") { + throw ( + result.error ?? + new Error(`Socket connect request failed with status ${result.status}`) + ); + } + + const body = result.body as + | Partial<{ url: string; expires_at: string }> + | undefined; + + const expiresAt = + typeof body?.expires_at === "string" ? Date.parse(body.expires_at) : NaN; + + if (typeof body?.url !== "string" || !body.url || Number.isNaN(expiresAt)) { + throw new Error("Invalid socket connect response"); + } + + return { signedUrl: body.url, expiresAt }; + } + private async requestWithRetries(req: ApiRequestConfig) { let lastError: unknown; From a80dea03ee1924b4608be977afe06bce71a66cc8 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 11:41:18 -0400 Subject: [PATCH 21/45] Move fetchSocketConnectUrl --- packages/client/src/api.ts | 36 ++----------------- packages/client/src/gatewayTransport.ts | 36 +++++++++++++++++++ packages/client/test/gatewayTransport.test.ts | 4 +++ 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index e14108d27..5042f10fd 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -1,8 +1,8 @@ import { Socket } from "phoenix"; import { - GatewayConnectResult, createGatewayTransport, + fetchSocketConnectUrl, } from "./gatewayTransport"; import { cacheUntilExpired, exponentialBackoffFullJitter } from "./helpers"; import { PageVisibilityManager } from "./pageVisibility"; @@ -130,7 +130,7 @@ class ApiClient { // Connect sockets via the WebSocket gateway API endpoint, which returns a signed ws:// or // wss:// connect URL. This signed URL is cached and used to reconnect until it expires. const connectUrlCache = cacheUntilExpired((signal: AbortSignal) => - this.fetchSocketConnectUrl(signal), + fetchSocketConnectUrl(this, signal), ); const transport = createGatewayTransport(WebSocket, { fetchConnectUrl: connectUrlCache.get, @@ -271,38 +271,6 @@ class ApiClient { } } - // Requests a signed WebSocket URL from the WebSocket gateway API endpoint. Rejects on any HTTP - // error or malformed body so the transport treats it like a failed connection attempt. - private async fetchSocketConnectUrl( - signal: AbortSignal, - ): Promise { - const result = await this.makeRequest({ - method: "POST", - url: "/v1/sockets/connect", - signal, - }); - - if (result.statusCode !== "ok") { - throw ( - result.error ?? - new Error(`Socket connect request failed with status ${result.status}`) - ); - } - - const body = result.body as - | Partial<{ url: string; expires_at: string }> - | undefined; - - const expiresAt = - typeof body?.expires_at === "string" ? Date.parse(body.expires_at) : NaN; - - if (typeof body?.url !== "string" || !body.url || Number.isNaN(expiresAt)) { - throw new Error("Invalid socket connect response"); - } - - return { signedUrl: body.url, expiresAt }; - } - private async requestWithRetries(req: ApiRequestConfig) { let lastError: unknown; diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index 959e5cc2f..10889ebaa 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -1,3 +1,4 @@ +import type ApiClient from "./api"; import { appendQueryString, getQueryString } from "./helpers"; /** @@ -56,6 +57,41 @@ export const SOCKET_STATES = { const CLOSE_CODE_NORMAL = 1000; const CLOSE_CODE_ABNORMAL = 1006; +/** + * Requests a signed WebSocket URL from the WebSocket gateway API endpoint. Rejects on any HTTP + * error or malformed body so the transport treats it like a failed connection attempt. + */ +export async function fetchSocketConnectUrl( + apiClient: ApiClient, + signal: AbortSignal, +): Promise { + const result = await apiClient.makeRequest({ + method: "POST", + url: "/v1/sockets/connect", + signal, + }); + + if (result.statusCode !== "ok") { + throw ( + result.error ?? + new Error(`Socket connect request failed with status ${result.status}`) + ); + } + + const body = result.body as + | Partial<{ url: string; expires_at: string }> + | undefined; + + const expiresAt = + typeof body?.expires_at === "string" ? Date.parse(body.expires_at) : NaN; + + if (typeof body?.url !== "string" || !body.url || Number.isNaN(expiresAt)) { + throw new Error("Invalid socket connect response"); + } + + return { signedUrl: body.url, expiresAt }; +} + export function createGatewayTransport( baseTransport: TransportConstructor, { fetchConnectUrl, onConnectUrlInvalid }: GatewayTransportOptions, diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts index 1dcf0a3c5..281dadde6 100644 --- a/packages/client/test/gatewayTransport.test.ts +++ b/packages/client/test/gatewayTransport.test.ts @@ -75,6 +75,10 @@ const SIGNED_URL = "wss://ws1.knock-dev.app/ws/v1/websocket?auth_token=jwt"; const MERGED_URL = `${SIGNED_URL}&user_token=ut&api_key=pk&branch_slug=null&vsn=2.0.0`; const PROTOCOLS = ["soap", "wamp"]; +describe("fetchSocketConnectUrl", () => { + // TODO +}); + describe("createGatewayTransport", () => { const fetchConnectUrl = vi.fn< (signal: AbortSignal) => Promise From 5b728eeab1ef038f39ae4c492abf22abfa181151 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 14:11:20 -0400 Subject: [PATCH 22/45] Update tests --- packages/client/test/gatewayTransport.test.ts | 108 +++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts index 281dadde6..718f47fd9 100644 --- a/packages/client/test/gatewayTransport.test.ts +++ b/packages/client/test/gatewayTransport.test.ts @@ -8,12 +8,14 @@ import { vi, } from "vitest"; +import type ApiClient from "../src/api"; import { GatewayConnectResult, SOCKET_STATES, TransportConstructor, WebSocketLike, createGatewayTransport, + fetchSocketConnectUrl, } from "../src/gatewayTransport"; class MockTransport implements WebSocketLike { @@ -76,7 +78,111 @@ const MERGED_URL = `${SIGNED_URL}&user_token=ut&api_key=pk&branch_slug=null&vsn= const PROTOCOLS = ["soap", "wamp"]; describe("fetchSocketConnectUrl", () => { - // TODO + const makeRequest = vi.fn(); + const apiClient = { makeRequest } as unknown as ApiClient; + const abortController = new AbortController(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns valid signed URL and expiration", async () => { + const expiresAt = "2024-01-01T00:00:00.000Z"; + + makeRequest.mockResolvedValueOnce({ + statusCode: "ok", + status: 200, + body: { url: SIGNED_URL, expires_at: expiresAt }, + }); + + const result = await fetchSocketConnectUrl( + apiClient, + abortController.signal, + ); + + expect(makeRequest).toHaveBeenCalledWith({ + method: "POST", + url: "/v1/sockets/connect", + signal: abortController.signal, + }); + expect(result).toEqual({ + signedUrl: SIGNED_URL, + expiresAt: Date.parse(expiresAt), + }); + }); + + test("throws an error when the request fails", async () => { + const error = new Error("network down"); + + makeRequest.mockResolvedValueOnce({ + statusCode: "error", + status: 0, + error, + }); + + await expect( + fetchSocketConnectUrl(apiClient, abortController.signal), + ).rejects.toBe(error); + }); + + test("throws an error when response status is not ok", async () => { + makeRequest.mockResolvedValueOnce({ + statusCode: "error", + status: 500, + }); + + await expect( + fetchSocketConnectUrl(apiClient, abortController.signal), + ).rejects.toThrow("Socket connect request failed with status 500"); + }); + + test("throws an error when expiration is not a string", async () => { + makeRequest.mockResolvedValueOnce({ + statusCode: "ok", + status: 200, + body: { url: SIGNED_URL, expires_at: 12345 }, + }); + + await expect( + fetchSocketConnectUrl(apiClient, abortController.signal), + ).rejects.toThrow("Invalid socket connect response"); + }); + + test("throws an error when expiration is not a valid date", async () => { + makeRequest.mockResolvedValueOnce({ + statusCode: "ok", + status: 200, + body: { url: SIGNED_URL, expires_at: "not-a-date" }, + }); + + await expect( + fetchSocketConnectUrl(apiClient, abortController.signal), + ).rejects.toThrow("Invalid socket connect response"); + }); + + test("throws an error when URL is missing from response", async () => { + makeRequest.mockResolvedValueOnce({ + statusCode: "ok", + status: 200, + body: { expires_at: "2024-01-01T00:00:00.000Z" }, + }); + + await expect( + fetchSocketConnectUrl(apiClient, abortController.signal), + ).rejects.toThrow("Invalid socket connect response"); + }); + + test("throws an error when URL is not a string", async () => { + makeRequest.mockResolvedValueOnce({ + statusCode: "ok", + status: 200, + body: { url: 12345, expires_at: "2024-01-01T00:00:00.000Z" }, + }); + + await expect( + fetchSocketConnectUrl(apiClient, abortController.signal), + ).rejects.toThrow("Invalid socket connect response"); + }); }); describe("createGatewayTransport", () => { From 81c0f4ce9de1b977c8d6659a14b35edae337564e Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 14:48:18 -0400 Subject: [PATCH 23/45] Update tests --- packages/client/test/api.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index c01c87453..0758c0fe1 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -734,6 +734,27 @@ describe("API Client", () => { expect(apiClient.socket).toBeDefined(); }); + test("configures socket with the gateway transport", () => { + vi.stubGlobal("window", {}); + + vi.mocked(Socket).mockClear(); + + new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: undefined, + }); + + const socketOpts = vi.mocked(Socket).mock.calls[0]![1] as Record< + string, + unknown + >; + expect(typeof socketOpts.transport).toBe("function"); + expect((socketOpts.transport as NewableFunction).name).toBe( + "GatewayTransport", + ); + }); + test("configures socket with reconnectAfterMs and rejoinAfterMs", () => { vi.stubGlobal("window", {}); From d972576ffc46d3c48d76289e42e22a238bdb9d91 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 14:51:47 -0400 Subject: [PATCH 24/45] Update tests --- packages/client/test/api.test.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 0758c0fe1..d83e89ca6 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -361,6 +361,38 @@ describe("API Client", () => { } }); + test("skips logging request errors when the signal was aborted", async () => { + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => { }); + try { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: undefined, + }); + const controller = new AbortController(); + controller.abort(); + const abortError = new DOMException( + "The operation was aborted.", + "AbortError", + ); + setFetchMock(apiClient, vi.fn().mockRejectedValue(abortError)); + + const response = await apiClient.makeRequest({ + method: "GET", + url: "/test", + signal: controller.signal, + }); + + expect(response.statusCode).toBe("error"); + expect(response.error).toBe(abortError); + expect(consoleSpy).not.toHaveBeenCalled(); + } finally { + consoleSpy.mockRestore(); + } + }); + test("handles API error responses with response metadata", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", From 788865efce1aa422011df347a6bbb02db5e9dda4 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 15:25:22 -0400 Subject: [PATCH 25/45] Update WebSocket mock --- packages/client/test/test-utils/mocks.ts | 34 +++++++++++++----------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/client/test/test-utils/mocks.ts b/packages/client/test/test-utils/mocks.ts index f6e2f0ff3..6b1ff061b 100644 --- a/packages/client/test/test-utils/mocks.ts +++ b/packages/client/test/test-utils/mocks.ts @@ -46,7 +46,7 @@ export const createMockKnock = (apiKey = "pk_test_12345") => { vi.spyOn(knock, "client").mockReturnValue( mockApiClient as unknown as ReturnType, ); - vi.spyOn(knock, "log").mockImplementation(() => {}); + vi.spyOn(knock, "log").mockImplementation(() => { }); return { knock, mockApiClient }; }; @@ -212,21 +212,23 @@ export const setupBrowserEnvironment = () => { }; // Simple WebSocket mock -export const createWebSocketMock = () => { - const mockWebSocket = { - send: vi.fn(), - close: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - readyState: 1, // OPEN - onopen: null, - onclose: null, - onmessage: null, - onerror: null, - }; - - return mockWebSocket; -}; +export const createWebSocketMock = () => + // Vitest 4 only lets a mock stand in as a constructor when its implementation is a `function` (or class), not an arrow function + vi.fn(function (url: string, protocols?: string | string[]) { + return { + url, + protocols, + readyState: 1, // OPEN + bufferedAmount: 0, + binaryType: "blob", + onopen: null, + onclose: null, + onmessage: null, + onerror: null, + send: vi.fn(), + close: vi.fn(), + }; + }); // Simple BroadcastChannel mock export const createBroadcastChannelMock = () => { From 551764a5bee6c88457353c054c567426747a6236 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 15:26:56 -0400 Subject: [PATCH 26/45] Update tests --- packages/client/test/api.test.ts | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index d83e89ca6..e574451ff 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -3,6 +3,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import packageJson from "../package.json"; import ApiClient from "../src/api"; +import type { TransportConstructor } from "../src/gatewayTransport"; + +import { createWebSocketMock } from "./test-utils/mocks"; const TEST_BRANCH_SLUG = "lorem-ipsum-dolor-branch"; @@ -1177,5 +1180,65 @@ describe("API Client", () => { onlineHandler(); expect(socket.disconnect).not.toHaveBeenCalled(); }); + + test("establishes a WebSocket connection via the gateway transport", async () => { + const MockWebSocket = createWebSocketMock(); + + vi.stubGlobal("window", {}); + vi.stubGlobal("WebSocket", MockWebSocket); + vi.mocked(Socket).mockClear(); + + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: "user_token_456", + disconnectOnPageHidden: false, + }); + + // The gateway's signed connect URL, as returned by `POST /v1/sockets/connect` + const signedUrl = + "wss://ws1.knock.app/ws/v1/websocket?auth_token=signed-token"; + const fetchMock = vi.fn().mockResolvedValue( + createJsonResponse({ + url: signedUrl, + expires_at: new Date(Date.now() + 60_000).toISOString(), + }), + ); + setFetchMock(apiClient, fetchMock); + + const socketOpts = vi.mocked(Socket).mock.calls[0]![1] as Record< + string, + unknown + >; + const GatewayTransport = socketOpts.transport as TransportConstructor; + + // The URL Phoenix would hand the transport, carrying the unsigned params + // (vsn, api_key, user_token) that the gateway's signed URL lacks. + const PHOENIX_URL = + "wss://api.knock.app/ws/v1/websocket?vsn=2.0.0&api_key=pk_test_12345&user_token=user_token_456"; + + // Call GatewayTransport directly to simulate Phoenix's socket establishing a connection + new GatewayTransport(PHOENIX_URL); + + await vi.waitFor(() => { expect(MockWebSocket).toHaveBeenCalledTimes(1); }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.knock.app/v1/sockets/connect", + expect.objectContaining({ method: "POST" }), + ); + + // Each underlying WebSocket is constructed with the signed URL merged + // with the original unsigned query params. + const expectedUrl = `${signedUrl}&vsn=2.0.0&api_key=pk_test_12345&user_token=user_token_456`; + expect(MockWebSocket).toHaveBeenNthCalledWith(1, expectedUrl, undefined); + + // Call GatewayTransport again to set up a new connection + new GatewayTransport(PHOENIX_URL); + await vi.waitFor(() => { expect(MockWebSocket).toHaveBeenCalledTimes(2) }); + + // No fetch should occur, as the previously fetched connect URL has not expired yet + expect(MockWebSocket).toHaveBeenNthCalledWith(2, expectedUrl, undefined); + }); }); }); From 2bca58d1ae55e59f3e1f9022c035be29687f4c95 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 15:46:04 -0400 Subject: [PATCH 27/45] Update tests --- packages/client/test/api.test.ts | 79 ++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index e574451ff..c91f668fc 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -1180,14 +1180,28 @@ describe("API Client", () => { onlineHandler(); expect(socket.disconnect).not.toHaveBeenCalled(); }); + }); - test("establishes a WebSocket connection via the gateway transport", async () => { - const MockWebSocket = createWebSocketMock(); + describe("WebSocket Connection via Gateway", () => { + const MockWebSocket = createWebSocketMock(); + + beforeEach(() => { + vi.useFakeTimers(); + vi.resetAllMocks(); + + vi.mocked(Socket).mockImplementation( + createSocketMock as unknown as () => Socket, + ); vi.stubGlobal("window", {}); vi.stubGlobal("WebSocket", MockWebSocket); - vi.mocked(Socket).mockClear(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + test("establishes a WebSocket connection via the gateway transport", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", apiKey: "pk_test_12345", @@ -1240,5 +1254,64 @@ describe("API Client", () => { // No fetch should occur, as the previously fetched connect URL has not expired yet expect(MockWebSocket).toHaveBeenNthCalledWith(2, expectedUrl, undefined); }); + + test("caches the signed WebSocket connect URL until it expires", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: "user_token_456", + disconnectOnPageHidden: false, + }); + + const CACHE_TTL_MS = 60_000; + const startDate = new Date(2024, 0, 1, 0, 0, 0); + vi.setSystemTime(startDate); + + // Each fetch returns a signed URL that expires `CACHE_TTL_MS` from + // whatever the (mocked) system time is at the time of the call. + const signedUrl = + "wss://ws1.knock.app/ws/v1/websocket?auth_token=signed-token"; + const fetchMock = vi.fn().mockImplementation(() => + createJsonResponse({ + url: signedUrl, + expires_at: new Date(Date.now() + CACHE_TTL_MS).toISOString(), + }), + ); + setFetchMock(apiClient, fetchMock); + + const socketOpts = vi.mocked(Socket).mock.calls[0]![1] as Record< + string, + unknown + >; + const GatewayTransport = socketOpts.transport as TransportConstructor; + + const PHOENIX_URL = + "wss://api.knock.app/ws/v1/websocket?vsn=2.0.0&api_key=pk_test_12345&user_token=user_token_456"; + + // The first connection fetches a signed connect URL. + new GatewayTransport(PHOENIX_URL); + await vi.waitFor(() => { + expect(MockWebSocket).toHaveBeenCalledTimes(1); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // A second connection, made before the cached URL expires, reuses it + // rather than fetching again. + new GatewayTransport(PHOENIX_URL); + await vi.waitFor(() => { + expect(MockWebSocket).toHaveBeenCalledTimes(2); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Advance the system clock past the cached URL's expiry. + vi.setSystemTime(new Date(startDate.getTime() + CACHE_TTL_MS + 1)); + + // A third connection, made after expiry, fetches a fresh signed URL. + new GatewayTransport(PHOENIX_URL); + await vi.waitFor(() => { + expect(MockWebSocket).toHaveBeenCalledTimes(3); + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); }); }); From d9a6b7e756949ac71b71261f694f9d17bbd7f8f1 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 15:50:14 -0400 Subject: [PATCH 28/45] Update tests --- packages/client/test/api.test.ts | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index c91f668fc..edb4b7170 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -1313,5 +1313,47 @@ describe("API Client", () => { }); expect(fetchMock).toHaveBeenCalledTimes(2); }); + + test("skips establishing a WebSocket connection when a signed URL fetch fails", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: "user_token_456", + disconnectOnPageHidden: false, + }); + + // A plain `Error` (no `.response`) isn't retryable, so this fails on + // the first attempt rather than after exhausting retries. + const fetchError = new Error("network down"); + const fetchMock = vi.fn().mockRejectedValue(fetchError); + setFetchMock(apiClient, fetchMock); + + const socketOpts = vi.mocked(Socket).mock.calls[0]![1] as Record< + string, + unknown + >; + const GatewayTransport = socketOpts.transport as TransportConstructor; + + const PHOENIX_URL = + "wss://api.knock.app/ws/v1/websocket?vsn=2.0.0&api_key=pk_test_12345&user_token=user_token_456"; + + const transport = new GatewayTransport(PHOENIX_URL); + const onClose = vi.fn(); + transport.onclose = onClose; + + // The transport reports the failure via `onclose`, since it never + // got far enough to open a connection. + await vi.waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // No WebSocket is ever constructed, since the connect URL fetch + // failed before a connection could be attempted. + expect(MockWebSocket).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const closeEvent = onClose.mock.calls[0]![0] as CloseEvent; + expect(closeEvent.wasClean).toBe(false); + }); }); }); From 2a0d8917f3dd05769d4ca17447445d4be990066f Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 16:12:36 -0400 Subject: [PATCH 29/45] Update tests --- packages/client/test/api.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index edb4b7170..27de04701 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -1245,14 +1245,7 @@ describe("API Client", () => { // Each underlying WebSocket is constructed with the signed URL merged // with the original unsigned query params. const expectedUrl = `${signedUrl}&vsn=2.0.0&api_key=pk_test_12345&user_token=user_token_456`; - expect(MockWebSocket).toHaveBeenNthCalledWith(1, expectedUrl, undefined); - - // Call GatewayTransport again to set up a new connection - new GatewayTransport(PHOENIX_URL); - await vi.waitFor(() => { expect(MockWebSocket).toHaveBeenCalledTimes(2) }); - - // No fetch should occur, as the previously fetched connect URL has not expired yet - expect(MockWebSocket).toHaveBeenNthCalledWith(2, expectedUrl, undefined); + expect(MockWebSocket).toHaveBeenLastCalledWith(expectedUrl, undefined); }); test("caches the signed WebSocket connect URL until it expires", async () => { From b126fa535d9e2f32bcbdc23e348548693d901aa6 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 16:18:40 -0400 Subject: [PATCH 30/45] Update tests --- packages/client/test/api.test.ts | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 27de04701..5c92a0888 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -1348,5 +1348,60 @@ describe("API Client", () => { const closeEvent = onClose.mock.calls[0]![0] as CloseEvent; expect(closeEvent.wasClean).toBe(false); }); + + test("invalidates the cached connect URL when the gateway rejects it, so the next connection refetches", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: "user_token_456", + disconnectOnPageHidden: false, + }); + + // Each fetch returns a signed URL with a distinct token, so the two + // connections' URLs can be told apart, and an expiry far enough out + // that the cache wouldn't naturally expire between them. + let fetchCount = 0; + const fetchMock = vi.fn().mockImplementation(() => { + fetchCount += 1; + return createJsonResponse({ + url: `wss://ws1.knock.app/ws/v1/websocket?auth_token=signed-token-${fetchCount}`, + expires_at: new Date(Date.now() + 60_000).toISOString(), + }); + }); + setFetchMock(apiClient, fetchMock); + + const socketOpts = vi.mocked(Socket).mock.calls[0]![1] as Record< + string, + unknown + >; + const GatewayTransport = socketOpts.transport as TransportConstructor; + + const PHOENIX_URL = + "wss://api.knock.app/ws/v1/websocket?vsn=2.0.0&api_key=pk_test_12345&user_token=user_token_456"; + + // The first connection fetches a signed URL and connects. + new GatewayTransport(PHOENIX_URL); + await vi.waitFor(() => { + expect(MockWebSocket).toHaveBeenCalledTimes(1); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // The underlying WebSocket closes before ever opening (`onopen` is + // never called), which looks like the signed URL is invalid + const ws = MockWebSocket.mock.results[0]!.value; + ws.onclose({ code: 1006, wasClean: false }); + + // A second connection, made while the cached URL would otherwise still + // be unexpired, refetches rather than reusing the invalidated URL. + new GatewayTransport(PHOENIX_URL); + await vi.waitFor(() => { + expect(MockWebSocket).toHaveBeenCalledTimes(2); + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [firstUrl] = MockWebSocket.mock.calls[0]!; + const [secondUrl] = MockWebSocket.mock.calls[1]!; + expect(secondUrl).not.toBe(firstUrl); + }); }); }); From 20ca5d35f626cc0daa8985138d0f61fd29d6d612 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 16:21:28 -0400 Subject: [PATCH 31/45] Update tests --- packages/client/test/api.test.ts | 102 +++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 5c92a0888..590191472 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -1403,5 +1403,107 @@ describe("API Client", () => { const [secondUrl] = MockWebSocket.mock.calls[1]!; expect(secondUrl).not.toBe(firstUrl); }); + + test("keeps the cached connect URL when a connection opens and later drops", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: "user_token_456", + disconnectOnPageHidden: false, + }); + + const signedUrl = + "wss://ws1.knock.app/ws/v1/websocket?auth_token=signed-token"; + const fetchMock = vi.fn().mockResolvedValue( + createJsonResponse({ + url: signedUrl, + expires_at: new Date(Date.now() + 60_000).toISOString(), + }), + ); + setFetchMock(apiClient, fetchMock); + + const socketOpts = vi.mocked(Socket).mock.calls[0]![1] as Record< + string, + unknown + >; + const GatewayTransport = socketOpts.transport as TransportConstructor; + + const PHOENIX_URL = + "wss://api.knock.app/ws/v1/websocket?vsn=2.0.0&api_key=pk_test_12345&user_token=user_token_456"; + + new GatewayTransport(PHOENIX_URL); + await vi.waitFor(() => { + expect(MockWebSocket).toHaveBeenCalledTimes(1); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // The connection opens successfully and only later drops uncleanly. + // Unlike a close that arrives before `onopen`, this isn't evidence the + // signed URL itself was bad, so the cache should survive it. + const ws = MockWebSocket.mock.results[0]!.value; + ws.onopen(); + ws.onclose({ wasClean: false }); + + // A second connection reuses the cached URL instead of refetching. + new GatewayTransport(PHOENIX_URL); + await vi.waitFor(() => { + expect(MockWebSocket).toHaveBeenCalledTimes(2); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("does not invalidate the cache when Phoenix closes a still-connecting socket", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: "user_token_456", + disconnectOnPageHidden: false, + }); + + const signedUrl = + "wss://ws1.knock.app/ws/v1/websocket?auth_token=signed-token"; + const fetchMock = vi.fn().mockResolvedValue( + createJsonResponse({ + url: signedUrl, + expires_at: new Date(Date.now() + 60_000).toISOString(), + }), + ); + setFetchMock(apiClient, fetchMock); + + const socketOpts = vi.mocked(Socket).mock.calls[0]![1] as Record< + string, + unknown + >; + const GatewayTransport = socketOpts.transport as TransportConstructor; + + const PHOENIX_URL = + "wss://api.knock.app/ws/v1/websocket?vsn=2.0.0&api_key=pk_test_12345&user_token=user_token_456"; + + const transport = new GatewayTransport(PHOENIX_URL); + await vi.waitFor(() => { + expect(MockWebSocket).toHaveBeenCalledTimes(1); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // The mock WebSocket's `close` doesn't emit a close event on its own + // the way a real one would, so wire it to do so here. + const ws = MockWebSocket.mock.results[0]!.value; + ws.close.mockImplementation(() => { + ws.onclose({ code: 1000, wasClean: false }); + }); + + // Phoenix tears the transport down (e.g. the caller disconnected) + // while the socket is still connecting, before it ever opened. This is + // a deliberate close, not a rejected signed URL, so it shouldn't + // invalidate the cache. + transport.close(); + + // A second connection reuses the cached URL instead of refetching. + new GatewayTransport(PHOENIX_URL); + await vi.waitFor(() => { + expect(MockWebSocket).toHaveBeenCalledTimes(2); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); }); }); From 229e1f082f1ac9536e4336af71b771fdc061b742 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 16:25:29 -0400 Subject: [PATCH 32/45] Update tests --- packages/client/test/api.test.ts | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 590191472..c9ee4e8bc 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -1505,5 +1505,90 @@ describe("API Client", () => { }); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + test("aborts the in-flight connect request when closed before it resolves, and does not cache the aborted result", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: "user_token_456", + disconnectOnPageHidden: false, + }); + + // Mimics real fetch: rejects with an AbortError once the request's + // signal is aborted, rather than ever resolving on its own. + const fetchMock = vi.fn().mockImplementation( + (_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + const abortError = new DOMException( + "The operation was aborted.", + "AbortError", + ); + const signal = init?.signal; + if (signal?.aborted) { + reject(abortError); + return; + } + signal?.addEventListener("abort", () => reject(abortError)); + }), + ); + setFetchMock(apiClient, fetchMock); + + const socketOpts = vi.mocked(Socket).mock.calls[0]![1] as Record< + string, + unknown + >; + const GatewayTransport = socketOpts.transport as TransportConstructor; + + const PHOENIX_URL = + "wss://api.knock.app/ws/v1/websocket?vsn=2.0.0&api_key=pk_test_12345&user_token=user_token_456"; + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => { }); + try { + const transport = new GatewayTransport(PHOENIX_URL); + const onClose = vi.fn(); + const onError = vi.fn(); + transport.onclose = onClose; + transport.onerror = onError; + + // Wait for the HTTP request to actually be in flight before closing. + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + transport.close(); + + await vi.waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // The abort signal propagated all the way through the cache, + // fetchSocketConnectUrl, and makeRequest/buildRequestInit, down to + // the real fetch call. + const requestInit = fetchMock.mock.calls[0]![1] as RequestInit; + expect(requestInit.signal?.aborted).toBe(true); + + // The abort is expected (our own close), so makeRequest's error + // logging is skipped rather than logging a spurious error. + expect(consoleSpy).not.toHaveBeenCalled(); + + // The close was clean and happened before any WebSocket existed. + expect(MockWebSocket).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + + const closeEvent = onClose.mock.calls[0]![0] as CloseEvent; + expect(closeEvent.wasClean).toBe(true); + } finally { + consoleSpy.mockRestore(); + } + + // The aborted fetch's result isn't cached, so a later connection + // attempt fetches again rather than reusing a cached value. + new GatewayTransport(PHOENIX_URL); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); }); }); From e2ccb491c3ada7035b7a4f0ee4c9f05ecd28dc36 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 16:49:03 -0400 Subject: [PATCH 33/45] Revert logging change to makeRequest --- packages/client/src/api.ts | 6 +----- packages/client/test/api.test.ts | 36 -------------------------------- 2 files changed, 1 insertion(+), 41 deletions(-) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 5042f10fd..15e4f6179 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -255,11 +255,7 @@ class ApiClient { status: result.status, }; } catch (e: unknown) { - // Aborted requests are deliberate (e.g. the socket transport cancelling a gateway fetch on - // disconnect), so we should skip logging them - if (!req.signal?.aborted) { - console.error(e); - } + console.error(e); const response = (e as ErrorWithResponse)?.response; return { diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index c9ee4e8bc..4721fb6e5 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -364,38 +364,6 @@ describe("API Client", () => { } }); - test("skips logging request errors when the signal was aborted", async () => { - const consoleSpy = vi - .spyOn(console, "error") - .mockImplementation(() => { }); - try { - const apiClient = new ApiClient({ - host: "https://api.knock.app", - apiKey: "pk_test_12345", - userToken: undefined, - }); - const controller = new AbortController(); - controller.abort(); - const abortError = new DOMException( - "The operation was aborted.", - "AbortError", - ); - setFetchMock(apiClient, vi.fn().mockRejectedValue(abortError)); - - const response = await apiClient.makeRequest({ - method: "GET", - url: "/test", - signal: controller.signal, - }); - - expect(response.statusCode).toBe("error"); - expect(response.error).toBe(abortError); - expect(consoleSpy).not.toHaveBeenCalled(); - } finally { - consoleSpy.mockRestore(); - } - }); - test("handles API error responses with response metadata", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", @@ -1569,10 +1537,6 @@ describe("API Client", () => { const requestInit = fetchMock.mock.calls[0]![1] as RequestInit; expect(requestInit.signal?.aborted).toBe(true); - // The abort is expected (our own close), so makeRequest's error - // logging is skipped rather than logging a spurious error. - expect(consoleSpy).not.toHaveBeenCalled(); - // The close was clean and happened before any WebSocket existed. expect(MockWebSocket).not.toHaveBeenCalled(); expect(onError).not.toHaveBeenCalled(); From 922d33902d5c54f76c7b6309067a9d51a42cef3e Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 17:48:00 -0400 Subject: [PATCH 34/45] Update tests --- packages/client/test/gatewayTransport.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts index 718f47fd9..68f96763c 100644 --- a/packages/client/test/gatewayTransport.test.ts +++ b/packages/client/test/gatewayTransport.test.ts @@ -210,6 +210,7 @@ describe("createGatewayTransport", () => { afterEach(() => { vi.useRealTimers(); + vi.unstubAllGlobals(); }); test("fetches the connect URL, then constructs the base transport with merged URL and protocols", async () => { @@ -524,6 +525,21 @@ describe("createGatewayTransport", () => { expect(onclose).toHaveBeenCalledTimes(1); }); + test("onclose is called with plain object event when CloseEvent is unsupported", () => { + vi.stubGlobal("CloseEvent", undefined); + + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + const onclose = vi.fn(); + gatewayTransport.onclose = onclose; + + gatewayTransport.close(); + + expect(onclose).toHaveBeenCalledTimes(1); + const event = onclose.mock.calls[0]?.[0]; + expect(Object.getPrototypeOf(event)).toBe(Object.prototype); + expect(event).toEqual({ code: 1000, reason: "", wasClean: true }); + }); + test("onConnectUrlInvalid fires when the inner transport closes before ever opening", async () => { new GatewayTransport(PHOENIX_URL, PROTOCOLS); await flushStart(); From e7eaaf5805923a130e3ddb7fe30eb3071c094f9d Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Mon, 19 Oct 2026 18:05:15 -0400 Subject: [PATCH 35/45] Call onConnectUrlInvalid when base transport constructor throws exception --- packages/client/src/gatewayTransport.ts | 3 +++ packages/client/test/gatewayTransport.test.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index 10889ebaa..69234a506 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -197,6 +197,9 @@ export function createGatewayTransport( try { this.inner = this.initBaseTransport(connectUrl); } catch (error) { + // The base transport's constructor threw an exception. We never even attempted a + // connection, so onclose won't fire. The connect URL must be invalid. + onConnectUrlInvalid?.(); this.fail(error); } } diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts index 68f96763c..7ce276ec7 100644 --- a/packages/client/test/gatewayTransport.test.ts +++ b/packages/client/test/gatewayTransport.test.ts @@ -504,6 +504,8 @@ describe("createGatewayTransport", () => { // Closing the failed gateway transport does not trigger another onclose event transport.close(); expect(onclose).toHaveBeenCalledTimes(1); + + expect(onConnectUrlInvalid).toHaveBeenCalledTimes(1); }); test("close() after the inner transport has closed on its own is a no-op", async () => { From 67ce89538bb2e13b8900031f294bace00bfb2022 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 20 Oct 2026 09:12:09 -0400 Subject: [PATCH 36/45] Update tests --- packages/client/src/gatewayTransport.ts | 2 ++ packages/client/test/gatewayTransport.test.ts | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index 69234a506..5c6f91bbf 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -234,6 +234,8 @@ export function createGatewayTransport( } private emitClose(code: number, reason: string, wasClean: boolean): void { + // Check this.settled to prevent triggering onclose more than once. For example, this could + // occur if the transport is closed manually inside of an onerror callback. if (this.settled) { return; } diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts index 7ce276ec7..e9b4be684 100644 --- a/packages/client/test/gatewayTransport.test.ts +++ b/packages/client/test/gatewayTransport.test.ts @@ -471,6 +471,29 @@ describe("createGatewayTransport", () => { expect(onclose).toHaveBeenCalledTimes(1); }); + test("close() called from onerror during a failed fetch emits a single clean close", async () => { + const error = new Error("network down"); + fetchConnectUrl.mockRejectedValueOnce(error); + + const gatewayTransport = new GatewayTransport(PHOENIX_URL, PROTOCOLS); + const onerror = vi.fn(() => gatewayTransport.close()); + const onclose = vi.fn(); + gatewayTransport.onerror = onerror; + gatewayTransport.onclose = onclose; + + await flushStart(); + + expect(onerror).toHaveBeenCalledWith(error); + + expect(onclose).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 1000, reason: "", wasClean: true }), + ); + expect(gatewayTransport.readyState).toBe(SOCKET_STATES.closed); + + // No inner transport was instantiated, as the fetch failed + expect(MockTransport.constructionCount).toBe(0); + }); + test("connection fails when the base transport cannot be instantiated", async () => { const error = new Error("Failed to instantiate base transport"); class ThrowingTransport { From f896685472fa5331fb7aa271c60a350557a343ea Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 20 Oct 2026 09:42:02 -0400 Subject: [PATCH 37/45] Disable API client's retries for gateway transport --- packages/client/src/gatewayTransport.ts | 3 ++ packages/client/test/api.test.ts | 48 +++++++++++++++++++ packages/client/test/gatewayTransport.test.ts | 1 + 3 files changed, 52 insertions(+) diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index 5c6f91bbf..10ed02757 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -69,6 +69,9 @@ export async function fetchSocketConnectUrl( method: "POST", url: "/v1/sockets/connect", signal, + // Set maxRetries to 0, so that the API client's own retry logic is never used. Phoenix + // handles retries automatically via the socket's reconnect backoff. + maxRetries: 0, }); if (result.statusCode !== "ok") { diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 4721fb6e5..609b353f7 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -1317,6 +1317,54 @@ describe("API Client", () => { expect(closeEvent.wasClean).toBe(false); }); + test("does not retry a retryable signed URL fetch failure, leaving retries to Phoenix", async () => { + const apiClient = new ApiClient({ + host: "https://api.knock.app", + apiKey: "pk_test_12345", + userToken: "user_token_456", + disconnectOnPageHidden: false, + }); + + // A 5xx is normally retried by `makeRequest`, but the gateway transport's fetch sets + // maxRetries to 0 so that each Phoenix connection attempt makes a single request. + const fetchMock = vi + .fn() + .mockResolvedValue(createJsonResponse({ error: "Server Error" }, 503)); + setFetchMock(apiClient, fetchMock); + skipRetryDelays(apiClient); + + const socketOpts = vi.mocked(Socket).mock.calls[0]![1] as Record< + string, + unknown + >; + const GatewayTransport = socketOpts.transport as TransportConstructor; + + const PHOENIX_URL = + "wss://api.knock.app/ws/v1/websocket?vsn=2.0.0&api_key=pk_test_12345&user_token=user_token_456"; + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => { }); + try { + const transport = new GatewayTransport(PHOENIX_URL); + const onClose = vi.fn(); + transport.onclose = onClose; + + await vi.waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(MockWebSocket).not.toHaveBeenCalled(); + + // An unclean close is what triggers Phoenix's reconnect backoff + const closeEvent = onClose.mock.calls[0]![0] as CloseEvent; + expect(closeEvent.wasClean).toBe(false); + } finally { + consoleSpy.mockRestore(); + } + }); + test("invalidates the cached connect URL when the gateway rejects it, so the next connection refetches", async () => { const apiClient = new ApiClient({ host: "https://api.knock.app", diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts index e9b4be684..7a3814ba8 100644 --- a/packages/client/test/gatewayTransport.test.ts +++ b/packages/client/test/gatewayTransport.test.ts @@ -104,6 +104,7 @@ describe("fetchSocketConnectUrl", () => { method: "POST", url: "/v1/sockets/connect", signal: abortController.signal, + maxRetries: 0, }); expect(result).toEqual({ signedUrl: SIGNED_URL, From a1fcc6b665cf2b0fe1c99f2b787e04c30a906741 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 21 Oct 2026 17:16:50 -0400 Subject: [PATCH 38/45] Use Pick with WebSocket type for WebSocketLike --- packages/client/src/gatewayTransport.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index 10ed02757..34600a1be 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -5,17 +5,18 @@ import { appendQueryString, getQueryString } from "./helpers"; * The signature that Phoenix expects socket transports to conform to, based on the native browser * `WebSocket`. Only fields and methods that Phoenix relies on are included here. */ -export interface WebSocketLike { - readonly readyState: number; - readonly bufferedAmount: number; - binaryType: BinaryType; - onopen: ((event: Event) => void) | null; - onclose: ((event: CloseEvent) => void) | null; - onmessage: ((event: MessageEvent) => void) | null; - onerror: ((event: Event) => void) | null; - send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void; - close(code?: number, reason?: string): void; -} +export type WebSocketLike = Pick< + WebSocket, + | "readyState" + | "bufferedAmount" + | "binaryType" + | "onopen" + | "onclose" + | "onmessage" + | "onerror" + | "send" + | "close" +>; export type TransportConstructor = new ( url: string, From d5811e9d422bb3b7930aa0aa46af089d1d719d64 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 21 Oct 2026 17:20:22 -0400 Subject: [PATCH 39/45] Update fetchSocketConnectUrl to use single object param --- packages/client/src/api.ts | 2 +- packages/client/src/gatewayTransport.ts | 11 +++++++---- packages/client/test/gatewayTransport.test.ts | 18 +++++++++--------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 15e4f6179..b84fe74bb 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -130,7 +130,7 @@ class ApiClient { // Connect sockets via the WebSocket gateway API endpoint, which returns a signed ws:// or // wss:// connect URL. This signed URL is cached and used to reconnect until it expires. const connectUrlCache = cacheUntilExpired((signal: AbortSignal) => - fetchSocketConnectUrl(this, signal), + fetchSocketConnectUrl({ apiClient: this, signal }), ); const transport = createGatewayTransport(WebSocket, { fetchConnectUrl: connectUrlCache.get, diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index 34600a1be..ee351734d 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -62,10 +62,13 @@ const CLOSE_CODE_ABNORMAL = 1006; * Requests a signed WebSocket URL from the WebSocket gateway API endpoint. Rejects on any HTTP * error or malformed body so the transport treats it like a failed connection attempt. */ -export async function fetchSocketConnectUrl( - apiClient: ApiClient, - signal: AbortSignal, -): Promise { +export async function fetchSocketConnectUrl({ + apiClient, + signal, +}: { + apiClient: ApiClient; + signal: AbortSignal; +}): Promise { const result = await apiClient.makeRequest({ method: "POST", url: "/v1/sockets/connect", diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts index 7a3814ba8..3fc6a0d6a 100644 --- a/packages/client/test/gatewayTransport.test.ts +++ b/packages/client/test/gatewayTransport.test.ts @@ -95,10 +95,10 @@ describe("fetchSocketConnectUrl", () => { body: { url: SIGNED_URL, expires_at: expiresAt }, }); - const result = await fetchSocketConnectUrl( + const result = await fetchSocketConnectUrl({ apiClient, - abortController.signal, - ); + signal: abortController.signal, + }); expect(makeRequest).toHaveBeenCalledWith({ method: "POST", @@ -122,7 +122,7 @@ describe("fetchSocketConnectUrl", () => { }); await expect( - fetchSocketConnectUrl(apiClient, abortController.signal), + fetchSocketConnectUrl({ apiClient, signal: abortController.signal }), ).rejects.toBe(error); }); @@ -133,7 +133,7 @@ describe("fetchSocketConnectUrl", () => { }); await expect( - fetchSocketConnectUrl(apiClient, abortController.signal), + fetchSocketConnectUrl({ apiClient, signal: abortController.signal }), ).rejects.toThrow("Socket connect request failed with status 500"); }); @@ -145,7 +145,7 @@ describe("fetchSocketConnectUrl", () => { }); await expect( - fetchSocketConnectUrl(apiClient, abortController.signal), + fetchSocketConnectUrl({ apiClient, signal: abortController.signal }), ).rejects.toThrow("Invalid socket connect response"); }); @@ -157,7 +157,7 @@ describe("fetchSocketConnectUrl", () => { }); await expect( - fetchSocketConnectUrl(apiClient, abortController.signal), + fetchSocketConnectUrl({ apiClient, signal: abortController.signal }), ).rejects.toThrow("Invalid socket connect response"); }); @@ -169,7 +169,7 @@ describe("fetchSocketConnectUrl", () => { }); await expect( - fetchSocketConnectUrl(apiClient, abortController.signal), + fetchSocketConnectUrl({ apiClient, signal: abortController.signal }), ).rejects.toThrow("Invalid socket connect response"); }); @@ -181,7 +181,7 @@ describe("fetchSocketConnectUrl", () => { }); await expect( - fetchSocketConnectUrl(apiClient, abortController.signal), + fetchSocketConnectUrl({ apiClient, signal: abortController.signal }), ).rejects.toThrow("Invalid socket connect response"); }); }); From edfa36f1079616bdddf7eebe031b6884d16e2450 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 21 Oct 2026 17:26:50 -0400 Subject: [PATCH 40/45] Move baseTransport into GatewayTransportOptions type --- packages/client/src/api.ts | 3 ++- packages/client/src/gatewayTransport.ts | 14 ++++++++++---- packages/client/test/gatewayTransport.test.ts | 15 +++++++++------ 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index b84fe74bb..4479039f0 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -132,7 +132,8 @@ class ApiClient { const connectUrlCache = cacheUntilExpired((signal: AbortSignal) => fetchSocketConnectUrl({ apiClient: this, signal }), ); - const transport = createGatewayTransport(WebSocket, { + const transport = createGatewayTransport({ + baseTransport: WebSocket, fetchConnectUrl: connectUrlCache.get, onConnectUrlInvalid: connectUrlCache.invalidate, }); diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index ee351734d..1aa55e446 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -29,6 +29,11 @@ export type GatewayConnectResult = { }; export type GatewayTransportOptions = { + /** + * The underlying transport constructor. An instance of this transport will be created once a + * connect URL has been fetched. + */ + baseTransport: TransportConstructor; /** * Resolves the signed WebSocket URL to connect to. Rejects on failure. */ @@ -99,10 +104,11 @@ export async function fetchSocketConnectUrl({ return { signedUrl: body.url, expiresAt }; } -export function createGatewayTransport( - baseTransport: TransportConstructor, - { fetchConnectUrl, onConnectUrlInvalid }: GatewayTransportOptions, -): TransportConstructor { +export function createGatewayTransport({ + baseTransport, + fetchConnectUrl, + onConnectUrlInvalid, +}: GatewayTransportOptions): TransportConstructor { // Use a named class, since Phoenix logs using the class name return class GatewayTransport implements WebSocketLike { binaryType: BinaryType = "blob"; diff --git a/packages/client/test/gatewayTransport.test.ts b/packages/client/test/gatewayTransport.test.ts index 3fc6a0d6a..057f44004 100644 --- a/packages/client/test/gatewayTransport.test.ts +++ b/packages/client/test/gatewayTransport.test.ts @@ -203,7 +203,8 @@ describe("createGatewayTransport", () => { MockTransport.instance = null; MockTransport.constructionCount = 0; - GatewayTransport = createGatewayTransport(MockTransport, { + GatewayTransport = createGatewayTransport({ + baseTransport: MockTransport, fetchConnectUrl, onConnectUrlInvalid, }); @@ -502,10 +503,11 @@ describe("createGatewayTransport", () => { throw error; } } - const ThrowingGatewayTransport = createGatewayTransport( - ThrowingTransport as unknown as TransportConstructor, - { fetchConnectUrl, onConnectUrlInvalid }, - ); + const ThrowingGatewayTransport = createGatewayTransport({ + baseTransport: ThrowingTransport as unknown as TransportConstructor, + fetchConnectUrl, + onConnectUrlInvalid, + }); const transport = new ThrowingGatewayTransport(PHOENIX_URL, PROTOCOLS); const onerror = vi.fn(); @@ -609,7 +611,8 @@ describe("createGatewayTransport", () => { }); test("omitting onConnectUrlInvalid does not throw when the url is invalid", async () => { - const NoCallbackTransport = createGatewayTransport(MockTransport, { + const NoCallbackTransport = createGatewayTransport({ + baseTransport: MockTransport, fetchConnectUrl, }); new NoCallbackTransport(PHOENIX_URL, PROTOCOLS); From 8aba86f1620ac8f8bd74b7d75ee685294c09251c Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 21 Oct 2026 17:34:00 -0400 Subject: [PATCH 41/45] Clear timeout --- packages/client/src/gatewayTransport.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/client/src/gatewayTransport.ts b/packages/client/src/gatewayTransport.ts index 1aa55e446..a1a229766 100644 --- a/packages/client/src/gatewayTransport.ts +++ b/packages/client/src/gatewayTransport.ts @@ -125,6 +125,7 @@ export function createGatewayTransport({ private opened = false; private closeRequested = false; private settled = false; + private readonly timeoutId: ReturnType; constructor( url: string, @@ -137,7 +138,7 @@ export function createGatewayTransport({ // Wait for the caller to finish setting up callbacks and properties. This pattern was copied // from Phoenix's LongPoll transport. - setTimeout(() => this.start(), 0); + this.timeoutId = setTimeout(() => this.start(), 0); } get readyState(): number { @@ -166,6 +167,9 @@ export function createGatewayTransport({ this.closeRequested = true; + // In case start() has not run yet, clear the timeout used to invoke it in the constructor + clearTimeout(this.timeoutId); + if (this.inner) { this.inner.close(code, reason); return; From 2f490916c0a687a01a357c43fe54e6bf8e5b943d Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Thu, 22 Oct 2026 15:11:06 -0400 Subject: [PATCH 42/45] Add comment --- packages/client/src/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 4479039f0..6fc4f44df 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -130,6 +130,8 @@ class ApiClient { // Connect sockets via the WebSocket gateway API endpoint, which returns a signed ws:// or // wss:// connect URL. This signed URL is cached and used to reconnect until it expires. const connectUrlCache = cacheUntilExpired((signal: AbortSignal) => + // If this fetch rejects, the error is handled by the gateway transport, and Phoenix + // considers it a failed connection attempt fetchSocketConnectUrl({ apiClient: this, signal }), ); const transport = createGatewayTransport({ From b219ed8b99fdd2ad3e6510151002c05b3a505917 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Thu, 22 Oct 2026 15:57:36 -0400 Subject: [PATCH 43/45] Update comments --- packages/client/src/helpers.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/client/src/helpers.ts b/packages/client/src/helpers.ts index 38e437c82..232cab93a 100644 --- a/packages/client/src/helpers.ts +++ b/packages/client/src/helpers.ts @@ -8,6 +8,10 @@ export function isValidUuid(uuid: string) { /** * Returns the query string for a given URL, minus the leading `"?"` character. When the URL has no * query string, returns the empty string. + * + * This function does not use the `URL` class due to known issues in React Native's implementation + * of `URL`. + * * @param url a valid string URL. For example, `"https://example.com/test?hello=world"`. */ export function getQueryString(url: string): string { @@ -17,6 +21,10 @@ export function getQueryString(url: string): string { /** * Appends a query string to a given URL. Note that this does not dedupe query params. + * + * This function does not use the `URL` class due to known issues in React Native's implementation + * of `URL`. + * * @param url a valid string URL. * @param queryString a query string, minus any leading `"?"` character. */ From 3d9c459836932dfe2dfab9b667ce99d0235a5ea8 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Thu, 22 Oct 2026 16:08:15 -0400 Subject: [PATCH 44/45] Use TanStack Store for caching fetched URL --- packages/client/src/helpers.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/client/src/helpers.ts b/packages/client/src/helpers.ts index 232cab93a..a5b09517e 100644 --- a/packages/client/src/helpers.ts +++ b/packages/client/src/helpers.ts @@ -1,3 +1,5 @@ +import { Store } from "@tanstack/store"; + const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; @@ -95,19 +97,20 @@ export function cacheUntilExpired< Args extends unknown[], T extends { expiresAt: number }, >(fetchFunc: (...args: Args) => Promise): CachedFetch { - let cached: T | null = null; + const store = new Store(null); const get = async (...args: Args) => { + const cached = store.state; if (cached && cached.expiresAt - CACHE_EXPIRY_PADDING_MS > Date.now()) { return cached; } const value = await fetchFunc(...args); - cached = value; + store.setState(() => value); return value; }; const invalidate = () => { - cached = null; + store.setState(() => null); }; return { get, invalidate }; From df64ad4d28ac7cb40fbd68a322c68e1ce451831b Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Fri, 23 Oct 2026 14:39:12 -0400 Subject: [PATCH 45/45] Add changeset --- .changeset/long-cougars-play.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/long-cougars-play.md diff --git a/.changeset/long-cougars-play.md b/.changeset/long-cougars-play.md new file mode 100644 index 000000000..9a1b3781d --- /dev/null +++ b/.changeset/long-cougars-play.md @@ -0,0 +1,5 @@ +--- +"@knocklabs/client": minor +--- + +Connect sockets via WebSocket gateway