diff --git a/javascript_client/src/subscriptions/ActionCableLink.ts b/javascript_client/src/subscriptions/ActionCableLink.ts index 79027e8aecd..1ca596864d5 100644 --- a/javascript_client/src/subscriptions/ActionCableLink.ts +++ b/javascript_client/src/subscriptions/ActionCableLink.ts @@ -10,15 +10,31 @@ type SubscriptionCallbacks = { received?: (payload: any) => void; }; +type CreateChannelId = () => string + +function createChannelId() { + // `crypto.randomUUID()` (Web Crypto API) is used because a low-entropy + // identifier here can collide between simultaneously-created subscriptions, + // and ActionCable routes incoming payloads by identifier — colliding + // subscriptions would receive each other's payloads. + return crypto.randomUUID() +} + class ActionCableLink extends ApolloLink { cable: Consumer channelName: string actionName: string connectionParams: ConnectionParams callbacks: SubscriptionCallbacks + createChannelId: CreateChannelId constructor(options: { - cable: Consumer, channelName?: string, actionName?: string, connectionParams?: ConnectionParams, callbacks?: SubscriptionCallbacks + cable: Consumer, + createChannelId?: CreateChannelId, + channelName?: string, + actionName?: string, + connectionParams?: ConnectionParams, + callbacks?: SubscriptionCallbacks, }) { super() this.cable = options.cable @@ -26,13 +42,14 @@ class ActionCableLink extends ApolloLink { this.actionName = options.actionName || "execute" this.connectionParams = options.connectionParams || {} this.callbacks = options.callbacks || {} + this.createChannelId = options.createChannelId || createChannelId } // Interestingly, this link does _not_ call through to `next` because // instead, it sends the request to ActionCable. request(operation: Operation, _next: NextLink): Observable { return new Observable((observer) => { - var channelId = Math.round(Date.now() + Math.random() * 100000).toString(16) + var channelId = this.createChannelId() var actionName = this.actionName var connectionParams = (typeof this.connectionParams === "function") ? this.connectionParams(operation) : this.connectionParams diff --git a/javascript_client/src/subscriptions/__tests__/ActionCableLinkTest.ts b/javascript_client/src/subscriptions/__tests__/ActionCableLinkTest.ts index a4314fdc8d3..ccc6f8bfdde 100644 --- a/javascript_client/src/subscriptions/__tests__/ActionCableLinkTest.ts +++ b/javascript_client/src/subscriptions/__tests__/ActionCableLinkTest.ts @@ -172,6 +172,31 @@ describe("ActionCableLink", () => { expect(subscription.params["test"]).toEqual(1) }) + it("generates a unique channelId for each subscription", () => { + var link = new ActionCableLink(options) + var channelIds = new Set() + var subscriptions: any[] = [] + + for (var i = 0; i < 1000; i++) { + var observable = link.request(operation, null as any) + var subscription: any = (observable.subscribe(() => null) as any)._cleanup + channelIds.add(subscription.params.channelId) + subscriptions.push(subscription) + } + + expect(channelIds.size).toBe(1000) + + subscriptions.forEach(function(s) { s.unsubscribe() }) + }) + + it("accepts an injected channel ID function", () => { + var link = new ActionCableLink({...options, createChannelId: () => "Channel-ID" }) + var observable = link.request(operation, null as any) + var subscription: any = (observable.subscribe(() => null) as any)._cleanup + expect(subscription.params.channelId).toEqual("Channel-ID") + subscription.unsubscribe() + }) + it('allows passing custom callbacks', () => { var connected = jest.fn() var received = jest.fn()