Skip to content

feat(PLA-3932): connect sockets via WebSocket gateway - #1085

Open
mattmikolay wants to merge 41 commits into
mattmik-kno-16047-sdks-add-maxretries-option-to-apirequestconfigfrom
mattmik-pla-3932-js-sdk-connect-sockets-using-websocket-gateway
Open

mattmikolay wants to merge 41 commits into
mattmik-kno-16047-sdks-add-maxretries-option-to-apirequestconfigfrom
mattmik-pla-3932-js-sdk-connect-sockets-using-websocket-gateway

Conversation

@mattmikolay

@mattmikolay mattmikolay commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Description

This PR updates the client SDK’s ApiClient to establish a WebSocket connection using the WebSocket gateway API endpoint. Now, before a WebSocket connection is attempted, we’ll make a request to the /v1/sockets/connect API endpoint, which returns a signed wss:// URL and an expiration time. We then establish a WebSocket connection using this signed URL, rather than the wss://api.knock.app/ws/v1/websocket endpoint which was previously hardcoded.

Phoenix’s Socket class supports specifying a custom transport. Normally this would be set to something like WebSocket or LongPoll, but any custom transport can be specified as long as it obeys the WebSocket contract. For example, instances of the transport should have a readyState property, a send method, a close method, etc.

So, this PR adds a custom transport GatewayTransport, constructed via the factory function createGatewayTransport. GatewayTransport is configured using a fetchConnectUrl option, a function used to make a request to the /v1/sockets/connect endpoint via the API client.

As soon as a GatewayTransport is instantiated, we call fetchConnectUrl to make the API request. Until this request resolves, the transport’s readyState remains CONNECTING. Assuming the request resolves successfully, GatewayTransport then delegates to a “base” transport (which will always be WebSocket) to establish a connection using the signed URL that was resolved via fetchConnectUrl. From that point forward, GatewayTransport’s properties and methods will always delegate to the instance of the base transport that was created.

A cacheUntilExpired helper function is used to skip unnecessary calls to the WebSocket gateway API endpoint; while a signed URL has not expired, any socket reconnection initiated by Phoenix will use that same signed URL. Once the URL expires, any reconnection will create an entirely new instance of GatewayTransport and call fetchConnectUrl to retrieve a new signed URL via the gateway endpoint.

Note that whenever fetchConnectUrl is called to make a request to the gateway endpoint, we set the maxRetries option to 0 so that ApiClient’s own retry logic is bypassed. This is because Phoenix’s Socket already retries using exponential backoff, as defined using the reconnectAfterMs option.

Checklist

  • Tests have been added for new features or major refactors to existing features.

Screenshots or videos

Loom demo and code walkthrough

@changeset-bot

changeset-bot Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: def24a4

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel

vercel Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
javascript-ms-teams-connect-example Ready Ready Preview Oct 22, 2026 8:10pm UTC
javascript-nextjs-example Ready Ready Preview Oct 22, 2026 8:10pm UTC
javascript-slack-connect-example Ready Ready Preview Oct 22, 2026 8:10pm UTC
javascript-slack-kit-example Ready Ready Preview Oct 22, 2026 8:10pm UTC

Request Review

@linear-code

linear-code Bot commented Oct 19, 2026

Copy link
Copy Markdown

PLA-3932

@codecov

codecov Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 7.15kB (1.18%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
@knocklabs/client-client-cjs 61.92kB 3.16kB (5.37%) ⬆️
@knocklabs/client-client-esm 74.17kB 4.0kB (5.7%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: @knocklabs/client-client-cjs

Assets Changed:

Asset Name Size Change Total Size Change (%)
api.js 239 bytes 5.11kB 4.9%
gatewayTransport.js (New) 2.42kB 2.42kB 100.0% 🚀
helpers.js 493 bytes 883 bytes 126.41% ⚠️

Files in api.js:

  • ./src/api.ts → Total Size: 8.13kB

Files in gatewayTransport.js:

  • ./src/gatewayTransport.ts → Total Size: 3.48kB

Files in helpers.js:

  • ./src/helpers.ts → Total Size: 1.25kB
view changes for bundle: @knocklabs/client-client-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
api.mjs 341 bytes 6.37kB 5.65% ⚠️
gatewayTransport.mjs (New) 3.06kB 3.06kB 100.0% 🚀
helpers.mjs 591 bytes 1.06kB 125.21% ⚠️

Files in api.mjs:

  • ./src/api.ts → Total Size: 8.0kB

Files in gatewayTransport.mjs:

  • ./src/gatewayTransport.ts → Total Size: 3.45kB

Files in helpers.mjs:

  • ./src/helpers.ts → Total Size: 1.23kB

onConnectUrlInvalid: connectUrlCache.invalidate,
});

this.socket = new Socket(`${this.host.replace("http", "ws")}/ws/v1`, {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL we’re passing to the Socket constructor here, ${this.host.replace("http", "ws")}/ws/v1, is overridden entirely by the signed URL we resolve in GatewayTransport.

@mattmikolay
mattmikolay marked this pull request as ready for review October 20, 2026 21:37
@mattmikolay
mattmikolay requested review from cjbell and kylemcd October 20, 2026 21:37

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not approved. This is a client SDK feature change, not an auto-release, dependency update, or docs-only PR, so product-team review is required. Human review is needed and no reviewers were assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: SDK approver

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not approved. This is a client SDK feature change, not a dependency or lock-file update, so it is outside this automation’s approval criteria. Human review is needed and no reviewers were assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Depdency updates (control, CLI, javascript, telegraph)

@cjbell cjbell left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great Matt. Super nice way to implement this. Very easy to follow code.

Comment on lines +20 to +24
export type TransportConstructor = new (
url: string,
protocols?: string | string[],
) => WebSocketLike;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: would be nice if this was an object instead of positional function params in case of future expansion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phoenix expects these two params, so we unfortunately can’t change this.

Comment thread packages/client/src/gatewayTransport.ts Outdated
* 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be derived from this type instead of manually declaring

Pick<WebSocket, "readyState" | "bufferedAmount" | "binaryType" | "onopen" | "onclose" | "onmessage" | "onerror" | "send" | "close">

Comment thread packages/client/src/gatewayTransport.ts Outdated
Comment on lines +64 to +67
export async function fetchSocketConnectUrl(
apiClient: ApiClient,
signal: AbortSignal,
): Promise<GatewayConnectResult> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same thing about function parmas here too, might just be worth doing a pass on this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one can in fact change; it’s not tied to Phoenix.

Comment on lines +132 to +134
const connectUrlCache = cacheUntilExpired((signal: AbortSignal) =>
fetchSocketConnectUrl(this, signal),
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't actually handle the failure here when this function throws fetchSocketConnectUrl

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this fetch rejects, the error is not handled here, but in the gateway transport. fetchSocketConnectUrl is called whenever connectUrlCache.get() is called, and connectUrlCache.get is passed to the gateway transport as fetchConnectUrl. Any rejection from fetchConnectUrl is handled in GatewayTransport’s start method:

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;
}

I’ll add a comment to clarify this.

onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;

private inner: WebSocketLike | null = null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be named self instead of inner?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inner field holds the wrapped WebSocket created by the initBaseTransport method. It’s a separate transport that GatewayTransport delegates to, but it’s not the GatewayTransport itself. this.self.send(data) reads like the gateway transport is calling itself rather than delegating.

// 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, should we just be returning errors to get them at callsite if needed instead of returning void?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above; we need to emit error events just like WebSocket.

const inner = new baseTransport(url, this.protocols);

inner.binaryType = this.binaryType;
inner.onopen = (ev) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's ev?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s the event that’s triggered when the WebSocket connection opens. These onopen, onmessage, onerror, and onclose handlers forward every event to the event handlers registered by Phoenix’s Socket so that Phoenix can manage socket state.

Comment on lines +13 to +16
export function getQueryString(url: string): string {
const idx = url.indexOf("?");
return idx === -1 ? "" : url.slice(idx + 1);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should use built in URL class to make this cleaner and less error prone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Native has some known issues related to URL and URLSearchParams. See react/react-native#38656 and the README for react-native-url-polyfill. It appears that these issues were fixed in React Native v0.84, but our React Native SDK currently supports back to v0.81:

"react-native": ">=0.81",

I’ll add a comment to explain this.

Comment on lines +23 to +27
export function appendQueryString(url: string, queryString: string): string {
return queryString
? `${url}${url.includes("?") ? "&" : "?"}${queryString}`
: url;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above. There are known issues with React Native’s implementation of URL.

Comment on lines +68 to +106
export type CachedFetch<Args extends unknown[], T> = {
/**
* Returns the cached value if still valid, otherwise calls `fetchFunc` with the same args and
* caches the result.
*/
get: (...args: Args) => Promise<T>;
/**
* Drops any cached value so the next `get` 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<T>): CachedFetch<Args, T> {
let cached: T | null = null;

const get = 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 { get, invalidate };
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should use tanstack store for this stuff, it's already a dep.

@codecov

codecov Bot commented Oct 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.13793% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 76.04%. Comparing base (ee69dec) to head (def24a4).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
packages/client/src/gatewayTransport.ts 98.92% 1 Missing ⚠️
Additional details and impacted files
@@                                         Coverage Diff                                          @@
##           mattmik-kno-16047-sdks-add-maxretries-option-to-apirequestconfig    #1085      +/-   ##
====================================================================================================
+ Coverage                                                             75.42%   76.04%   +0.61%     
====================================================================================================
  Files                                                                   139      140       +1     
  Lines                                                                  4379     4495     +116     
  Branches                                                               1288     1310      +22     
====================================================================================================
+ Hits                                                                   3303     3418     +115     
- Misses                                                                  976      977       +1     
  Partials                                                                100      100              
Files with missing lines Coverage Δ
packages/client/src/api.ts 91.44% <100.00%> (+0.13%) ⬆️
packages/client/src/helpers.ts 100.00% <100.00%> (ø)
packages/client/src/gatewayTransport.ts 98.92% <98.92%> (ø)

This branch was successfully deployed

4 active deployments
Preview – javascript-ms-teams-connect-example — def24a4c Deployed Oct 22, 2026 by vercel[bot]
Preview – javascript-nextjs-example — def24a4c Deployed Oct 22, 2026 by vercel[bot]
Preview – javascript-slack-kit-example — def24a4c Deployed Oct 22, 2026 by vercel[bot]
Preview – javascript-slack-connect-example — def24a4c Deployed Oct 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants