From a1a746898299fb5317b40e26e2e445d3eec59f53 Mon Sep 17 00:00:00 2001 From: Mathias Grimm Date: Wed, 25 Feb 2026 21:52:26 -0300 Subject: [PATCH 1/2] wip --- packages/laravel-echo/src/channel/index.ts | 3 + .../laravel-echo/src/channel/poll-channel.ts | 139 ++++++ .../src/channel/poll-presence-channel.ts | 89 ++++ .../src/channel/poll-private-channel.ts | 13 + packages/laravel-echo/src/connector/index.ts | 1 + .../src/connector/poll-connector.ts | 321 ++++++++++++ packages/laravel-echo/src/echo.ts | 15 + .../tests/channel/poll-channel.test.ts | 149 ++++++ .../channel/poll-presence-channel.test.ts | 118 +++++ .../tests/connector/poll-connector.test.ts | 458 ++++++++++++++++++ packages/laravel-echo/tests/echo.test.ts | 4 + 11 files changed, 1310 insertions(+) create mode 100644 packages/laravel-echo/src/channel/poll-channel.ts create mode 100644 packages/laravel-echo/src/channel/poll-presence-channel.ts create mode 100644 packages/laravel-echo/src/channel/poll-private-channel.ts create mode 100644 packages/laravel-echo/src/connector/poll-connector.ts create mode 100644 packages/laravel-echo/tests/channel/poll-channel.test.ts create mode 100644 packages/laravel-echo/tests/channel/poll-presence-channel.test.ts create mode 100644 packages/laravel-echo/tests/connector/poll-connector.test.ts diff --git a/packages/laravel-echo/src/channel/index.ts b/packages/laravel-echo/src/channel/index.ts index 1b134bd9..8acc338d 100644 --- a/packages/laravel-echo/src/channel/index.ts +++ b/packages/laravel-echo/src/channel/index.ts @@ -11,3 +11,6 @@ export * from "./null-channel"; export * from "./null-private-channel"; export * from "./null-encrypted-private-channel"; export * from "./null-presence-channel"; +export * from "./poll-channel"; +export * from "./poll-private-channel"; +export * from "./poll-presence-channel"; diff --git a/packages/laravel-echo/src/channel/poll-channel.ts b/packages/laravel-echo/src/channel/poll-channel.ts new file mode 100644 index 00000000..0527ed03 --- /dev/null +++ b/packages/laravel-echo/src/channel/poll-channel.ts @@ -0,0 +1,139 @@ +import { EventFormatter } from "../util"; +import { Channel } from "./channel"; +import type { EchoOptionsWithDefaults } from "../connector"; + +/** + * This class represents a poll channel. + */ +export class PollChannel extends Channel { + /** + * The name of the channel. + */ + name: string; + + /** + * The event formatter. + */ + eventFormatter: EventFormatter; + + /** + * Local event listener registry. + */ + private listeners: Map> = new Map(); + + /** + * Subscription success callbacks. + */ + private subscribedCallbacks: Set = new Set(); + + /** + * Error callbacks. + */ + private errorCallbacks: Set = new Set(); + + /** + * Create a new class instance. + */ + constructor(name: string, options: EchoOptionsWithDefaults<"poll">) { + super(); + this.name = name; + this.options = options; + this.eventFormatter = new EventFormatter(this.options.namespace); + } + + /** + * Dispatch an event to local listeners. + */ + dispatch(event: string, data: any): void { + const callbacks = this.listeners.get(event); + if (callbacks) { + callbacks.forEach((cb) => cb(data)); + } + } + + /** + * Notify that subscription succeeded. + */ + notifySubscribed(): void { + this.subscribedCallbacks.forEach((cb) => cb()); + } + + /** + * Notify that an error occurred. + */ + notifyError(error: any): void { + this.errorCallbacks.forEach((cb) => cb(error)); + } + + /** + * Subscribe to a channel. + */ + subscribe(): void { + // + } + + /** + * Unsubscribe from a channel. + */ + unsubscribe(): void { + this.listeners.clear(); + this.subscribedCallbacks.clear(); + this.errorCallbacks.clear(); + } + + /** + * Listen for an event on the channel instance. + */ + listen(event: string, callback: CallableFunction): this { + this.on(this.eventFormatter.format(event), callback); + return this; + } + + /** + * Listen for all events on the channel instance. + */ + listenToAll(callback: CallableFunction): this { + this.on("*", callback); + return this; + } + + /** + * Stop listening for an event on the channel instance. + */ + stopListening(event: string, callback?: CallableFunction): this { + const formatted = this.eventFormatter.format(event); + if (callback) { + this.listeners.get(formatted)?.delete(callback); + } else { + this.listeners.delete(formatted); + } + return this; + } + + /** + * Register a callback to be called anytime a subscription succeeds. + */ + subscribed(callback: CallableFunction): this { + this.subscribedCallbacks.add(callback); + return this; + } + + /** + * Register a callback to be called anytime an error occurs. + */ + error(callback: CallableFunction): this { + this.errorCallbacks.add(callback); + return this; + } + + /** + * Bind a channel to an event. + */ + on(event: string, callback: CallableFunction): this { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)!.add(callback); + return this; + } +} diff --git a/packages/laravel-echo/src/channel/poll-presence-channel.ts b/packages/laravel-echo/src/channel/poll-presence-channel.ts new file mode 100644 index 00000000..16cbeca7 --- /dev/null +++ b/packages/laravel-echo/src/channel/poll-presence-channel.ts @@ -0,0 +1,89 @@ +import type { PresenceChannel } from "./presence-channel"; +import { PollPrivateChannel } from "./poll-private-channel"; + +/** + * This class represents a poll presence channel. + */ +export class PollPresenceChannel + extends PollPrivateChannel + implements PresenceChannel +{ + /** + * Callbacks for the here event. + */ + private hereCallbacks: Set = new Set(); + + /** + * Callbacks for the joining event. + */ + private joiningCallbacks: Set = new Set(); + + /** + * Callbacks for the leaving event. + */ + private leavingCallbacks: Set = new Set(); + + /** + * Register a callback to be called anytime the member list changes. + */ + here(callback: CallableFunction): this { + this.hereCallbacks.add(callback); + return this; + } + + /** + * Listen for someone joining the channel. + */ + joining(callback: CallableFunction): this { + this.joiningCallbacks.add(callback); + return this; + } + + /** + * Listen for someone leaving the channel. + */ + leaving(callback: CallableFunction): this { + this.leavingCallbacks.add(callback); + return this; + } + + /** + * Send a whisper event to other clients in the channel. + */ + whisper(_eventName: string, _data: Record): this { + return this; + } + + /** + * Update presence data from the poll response. + */ + updatePresence(data: { + members: any[]; + joined: any[]; + left: any[]; + }): void { + if (data.members) { + this.hereCallbacks.forEach((cb) => cb(data.members)); + } + if (data.joined) { + data.joined.forEach((member: any) => { + this.joiningCallbacks.forEach((cb) => cb(member)); + }); + } + if (data.left) { + data.left.forEach((member: any) => { + this.leavingCallbacks.forEach((cb) => cb(member)); + }); + } + } + + /** + * Unsubscribe from a channel. + */ + unsubscribe(): void { + super.unsubscribe(); + this.hereCallbacks.clear(); + this.joiningCallbacks.clear(); + this.leavingCallbacks.clear(); + } +} diff --git a/packages/laravel-echo/src/channel/poll-private-channel.ts b/packages/laravel-echo/src/channel/poll-private-channel.ts new file mode 100644 index 00000000..4063a36d --- /dev/null +++ b/packages/laravel-echo/src/channel/poll-private-channel.ts @@ -0,0 +1,13 @@ +import { PollChannel } from "./poll-channel"; + +/** + * This class represents a poll private channel. + */ +export class PollPrivateChannel extends PollChannel { + /** + * Send a whisper event to other clients in the channel. + */ + whisper(_eventName: string, _data: Record): this { + return this; + } +} diff --git a/packages/laravel-echo/src/connector/index.ts b/packages/laravel-echo/src/connector/index.ts index 92d4ad5e..576f4b09 100644 --- a/packages/laravel-echo/src/connector/index.ts +++ b/packages/laravel-echo/src/connector/index.ts @@ -2,3 +2,4 @@ export * from "./connector"; export * from "./pusher-connector"; export * from "./socketio-connector"; export * from "./null-connector"; +export * from "./poll-connector"; diff --git a/packages/laravel-echo/src/connector/poll-connector.ts b/packages/laravel-echo/src/connector/poll-connector.ts new file mode 100644 index 00000000..fd9c4cd5 --- /dev/null +++ b/packages/laravel-echo/src/connector/poll-connector.ts @@ -0,0 +1,321 @@ +import { + PollChannel, + PollPresenceChannel, + PollPrivateChannel, +} from "../channel"; +import type { ConnectionStatus } from "../echo"; +import { Connector, type EchoOptionsWithDefaults } from "./connector"; + +type AnyPollChannel = PollChannel | PollPrivateChannel | PollPresenceChannel; + +export type PollOptions = EchoOptionsWithDefaults<"poll"> & { + pollInterval?: number; + pollEndpoint?: string; +}; + +/** + * This class creates a connector that polls a Laravel backend. + */ +export class PollConnector extends Connector< + "poll", + PollChannel, + PollPrivateChannel, + PollPresenceChannel +> { + /** + * All of the subscribed channel names. + */ + channels!: Record; + + declare options: PollOptions; + + /** + * The polling interval timer. + */ + private pollTimer!: ReturnType | null; + + /** + * Cursor for event-sourcing. + */ + private lastEventId!: string | null; + + /** + * A unique socket ID for this client. + */ + private _socketId!: string; + + /** + * Current connection status. + */ + private status!: ConnectionStatus; + + /** + * Connection status change subscribers. + */ + private statusCallbacks!: Set<(status: ConnectionStatus) => void>; + + /** + * Whether a poll request is currently in flight. + */ + private polling!: boolean; + + /** + * Create a fresh connection. + * + * Note: All fields are initialized here rather than as field initializers + * because the parent Connector constructor calls connect() before + * subclass field initializers run. + */ + connect(): void { + this.channels = {}; + this.pollTimer = null; + this.lastEventId = null; + this.status = "connecting"; + this.statusCallbacks = new Set(); + this.polling = false; + this._socketId = `${Math.random().toString(36).substring(2)}.${Math.random().toString(36).substring(2)}`; + this.startPolling(); + } + + /** + * Listen for an event on a channel instance. + */ + listen( + name: string, + event: string, + callback: CallableFunction, + ): AnyPollChannel { + return this.channel(name).listen(event, callback); + } + + /** + * Get a channel instance by name. + */ + channel(name: string): PollChannel { + if (!this.channels[name]) { + this.channels[name] = new PollChannel(name, this.options); + } + return this.channels[name] as PollChannel; + } + + /** + * Get a private channel instance by name. + */ + privateChannel(name: string): PollPrivateChannel { + const prefixed = "private-" + name; + if (!this.channels[prefixed]) { + this.channels[prefixed] = new PollPrivateChannel( + prefixed, + this.options, + ); + } + return this.channels[prefixed] as PollPrivateChannel; + } + + /** + * Get a presence channel instance by name. + */ + presenceChannel(name: string): PollPresenceChannel { + const prefixed = "presence-" + name; + if (!this.channels[prefixed]) { + this.channels[prefixed] = new PollPresenceChannel( + prefixed, + this.options, + ); + } + return this.channels[prefixed] as PollPresenceChannel; + } + + /** + * Leave the given channel, as well as its private and presence variants. + */ + leave(name: string): void { + [ + name, + "private-" + name, + "private-encrypted-" + name, + "presence-" + name, + ].forEach((n) => this.leaveChannel(n)); + } + + /** + * Leave the given channel. + */ + leaveChannel(name: string): void { + if (this.channels[name]) { + this.channels[name].unsubscribe(); + delete this.channels[name]; + } + } + + /** + * Get the socket ID for the connection. + */ + socketId(): string | undefined { + return this._socketId; + } + + /** + * Get the current connection status. + */ + connectionStatus(): ConnectionStatus { + return this.status; + } + + /** + * Subscribe to connection status changes. + */ + onConnectionChange( + callback: (status: ConnectionStatus) => void, + ): () => void { + this.statusCallbacks.add(callback); + return () => { + this.statusCallbacks.delete(callback); + }; + } + + /** + * Disconnect from the Echo server. + */ + disconnect(): void { + this.stopPolling(); + this.setStatus("disconnected"); + Object.keys(this.channels).forEach((name) => { + this.channels[name].unsubscribe(); + }); + this.channels = {}; + this.lastEventId = null; + } + + /** + * Set the connection status and notify subscribers. + */ + private setStatus(status: ConnectionStatus): void { + if (this.status !== status) { + this.status = status; + this.statusCallbacks.forEach((cb) => cb(status)); + } + } + + /** + * Start the polling loop. + */ + private startPolling(): void { + const interval = this.options.pollInterval ?? 5000; + + this.poll(); + + this.pollTimer = setInterval(() => { + this.poll(); + }, interval); + } + + /** + * Stop the polling loop. + */ + private stopPolling(): void { + if (this.pollTimer !== null) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + /** + * Perform a single poll request. + */ + private async poll(): Promise { + if (this.polling) { + return; + } + + const channelNames = Object.keys(this.channels); + + if (channelNames.length === 0 && this.lastEventId !== null) { + return; + } + + this.polling = true; + + try { + const endpoint = + this.options.pollEndpoint ?? "/broadcasting/poll"; + + const params = new URLSearchParams(); + channelNames.forEach((name) => { + params.append("channels[]", name); + }); + if (this.lastEventId) { + params.append("lastEventId", this.lastEventId); + } + + const url = `${endpoint}?${params.toString()}`; + + const headers: Record = { + Accept: "application/json", + "X-Socket-ID": this._socketId, + ...this.options.auth.headers, + }; + + const response = await fetch(url, { + method: "GET", + headers, + credentials: "same-origin", + }); + + if (!response.ok) { + throw new Error(`Poll request failed: ${response.status}`); + } + + const data = await response.json(); + + if (data.lastEventId) { + this.lastEventId = data.lastEventId; + } + + if (this.status !== "connected") { + this.setStatus("connected"); + Object.values(this.channels).forEach((channel) => { + (channel as PollChannel).notifySubscribed(); + }); + } + + if (data.events && Array.isArray(data.events)) { + for (const event of data.events) { + const ch = this.channels[event.channel]; + if (ch) { + (ch as PollChannel).dispatch(event.event, event.data); + } + } + } + + if (data.presence) { + for (const [channelName, presenceData] of Object.entries( + data.presence, + )) { + const ch = this.channels[channelName]; + if (ch && ch instanceof PollPresenceChannel) { + ch.updatePresence( + presenceData as { + members: any[]; + joined: any[]; + left: any[]; + }, + ); + } + } + } + } catch (error) { + Object.values(this.channels).forEach((channel) => { + (channel as PollChannel).notifyError(error); + }); + + if (this.status === "connected") { + this.setStatus("reconnecting"); + } else if (this.status !== "reconnecting") { + this.setStatus("failed"); + } + } finally { + this.polling = false; + } + } +} diff --git a/packages/laravel-echo/src/echo.ts b/packages/laravel-echo/src/echo.ts index fd3bcffe..de764e6d 100644 --- a/packages/laravel-echo/src/echo.ts +++ b/packages/laravel-echo/src/echo.ts @@ -12,13 +12,18 @@ import { SocketIoChannel, SocketIoPresenceChannel, SocketIoPrivateChannel, + PollChannel, + PollPresenceChannel, + PollPrivateChannel, type PresenceChannel, } from "./channel"; import { Connector, NullConnector, + PollConnector, PusherConnector, SocketIoConnector, + type PollOptions, type PusherOptions, } from "./connector"; import { isConstructor } from "./util"; @@ -85,6 +90,8 @@ export default class Echo { }); } else if (this.options.broadcaster === "socket.io") { this.connector = new SocketIoConnector(this.options); + } else if (this.options.broadcaster === "poll") { + this.connector = new PollConnector(this.options); } else if (this.options.broadcaster === "null") { this.connector = new NullConnector(this.options); } else if ( @@ -330,6 +337,14 @@ export type Broadcaster = { presence: SocketIoPresenceChannel; options: GenericOptions<"socket.io">; }; + poll: { + connector: PollConnector; + public: PollChannel; + private: PollPrivateChannel; + encrypted: never; + presence: PollPresenceChannel; + options: GenericOptions<"poll"> & Partial; + }; null: { connector: NullConnector; public: NullChannel; diff --git a/packages/laravel-echo/tests/channel/poll-channel.test.ts b/packages/laravel-echo/tests/channel/poll-channel.test.ts new file mode 100644 index 00000000..02a4e1b6 --- /dev/null +++ b/packages/laravel-echo/tests/channel/poll-channel.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { PollChannel } from "../../src/channel"; +import { Connector } from "../../src/connector"; + +describe("PollChannel", () => { + let channel: PollChannel; + + beforeEach(() => { + channel = new PollChannel("some.channel", { + broadcaster: "poll", + ...Connector._defaultOptions, + namespace: false, + }); + }); + + test("triggers all listeners for an event", () => { + const l1 = vi.fn(); + const l2 = vi.fn(); + const l3 = vi.fn(); + channel.listen("MyEvent", l1); + channel.listen("MyEvent", l2); + channel.listen("MyOtherEvent", l3); + + channel.dispatch("MyEvent", { foo: "bar" }); + + expect(l1).toHaveBeenCalledWith({ foo: "bar" }); + expect(l2).toHaveBeenCalledWith({ foo: "bar" }); + expect(l3).not.toHaveBeenCalled(); + + channel.dispatch("MyOtherEvent", { baz: 1 }); + + expect(l3).toHaveBeenCalledWith({ baz: 1 }); + }); + + test("can remove a specific listener for an event", () => { + const l1 = vi.fn(); + const l2 = vi.fn(); + const l3 = vi.fn(); + channel.listen("MyEvent", l1); + channel.listen("MyEvent", l2); + channel.listen("MyOtherEvent", l3); + + channel.stopListening("MyEvent", l1); + + channel.dispatch("MyEvent", {}); + + expect(l1).not.toHaveBeenCalled(); + expect(l2).toHaveBeenCalled(); + expect(l3).not.toHaveBeenCalled(); + + channel.dispatch("MyOtherEvent", {}); + + expect(l3).toHaveBeenCalled(); + }); + + test("can remove all listeners for an event", () => { + const l1 = vi.fn(); + const l2 = vi.fn(); + const l3 = vi.fn(); + channel.listen("MyEvent", l1); + channel.listen("MyEvent", l2); + channel.listen("MyOtherEvent", l3); + + channel.stopListening("MyEvent"); + + channel.dispatch("MyEvent", {}); + + expect(l1).not.toHaveBeenCalled(); + expect(l2).not.toHaveBeenCalled(); + expect(l3).not.toHaveBeenCalled(); + + channel.dispatch("MyOtherEvent", {}); + + expect(l3).toHaveBeenCalled(); + }); + + test("formats event names with namespace", () => { + const namespaced = new PollChannel("test", { + broadcaster: "poll", + ...Connector._defaultOptions, + namespace: "App.Events", + }); + + const cb = vi.fn(); + namespaced.listen("OrderShipped", cb); + + // EventFormatter converts "App.Events.OrderShipped" to "App\\Events\\OrderShipped" + namespaced.dispatch("App\\Events\\OrderShipped", { id: 1 }); + + expect(cb).toHaveBeenCalledWith({ id: 1 }); + }); + + test("formats dot-prefixed events without namespace", () => { + const namespaced = new PollChannel("test", { + broadcaster: "poll", + ...Connector._defaultOptions, + namespace: "App.Events", + }); + + const cb = vi.fn(); + namespaced.listen(".custom-event", cb); + + namespaced.dispatch("custom-event", { ok: true }); + + expect(cb).toHaveBeenCalledWith({ ok: true }); + }); + + test("notifySubscribed fires subscribed callbacks", () => { + const cb1 = vi.fn(); + const cb2 = vi.fn(); + channel.subscribed(cb1); + channel.subscribed(cb2); + + channel.notifySubscribed(); + + expect(cb1).toHaveBeenCalledOnce(); + expect(cb2).toHaveBeenCalledOnce(); + }); + + test("notifyError fires error callbacks", () => { + const cb = vi.fn(); + channel.error(cb); + + const err = new Error("fail"); + channel.notifyError(err); + + expect(cb).toHaveBeenCalledWith(err); + }); + + test("unsubscribe clears all listeners and callbacks", () => { + const listener = vi.fn(); + const subscribed = vi.fn(); + const error = vi.fn(); + + channel.listen("MyEvent", listener); + channel.subscribed(subscribed); + channel.error(error); + + channel.unsubscribe(); + + channel.dispatch("MyEvent", {}); + channel.notifySubscribed(); + channel.notifyError(new Error("fail")); + + expect(listener).not.toHaveBeenCalled(); + expect(subscribed).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/laravel-echo/tests/channel/poll-presence-channel.test.ts b/packages/laravel-echo/tests/channel/poll-presence-channel.test.ts new file mode 100644 index 00000000..74035581 --- /dev/null +++ b/packages/laravel-echo/tests/channel/poll-presence-channel.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { PollPresenceChannel } from "../../src/channel"; +import { Connector } from "../../src/connector"; + +describe("PollPresenceChannel", () => { + let channel: PollPresenceChannel; + + beforeEach(() => { + channel = new PollPresenceChannel("presence-chat", { + broadcaster: "poll", + ...Connector._defaultOptions, + namespace: false, + }); + }); + + test("here() callbacks fire with members list", () => { + const cb = vi.fn(); + channel.here(cb); + + channel.updatePresence({ + members: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }], + joined: [], + left: [], + }); + + expect(cb).toHaveBeenCalledWith([ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + ]); + }); + + test("joining() callbacks fire for each joined member", () => { + const cb = vi.fn(); + channel.joining(cb); + + channel.updatePresence({ + members: [], + joined: [{ id: 3, name: "Charlie" }, { id: 4, name: "Diana" }], + left: [], + }); + + expect(cb).toHaveBeenCalledTimes(2); + expect(cb).toHaveBeenCalledWith({ id: 3, name: "Charlie" }); + expect(cb).toHaveBeenCalledWith({ id: 4, name: "Diana" }); + }); + + test("leaving() callbacks fire for each left member", () => { + const cb = vi.fn(); + channel.leaving(cb); + + channel.updatePresence({ + members: [], + joined: [], + left: [{ id: 1, name: "Alice" }], + }); + + expect(cb).toHaveBeenCalledWith({ id: 1, name: "Alice" }); + }); + + test("multiple callbacks can be registered for each event", () => { + const here1 = vi.fn(); + const here2 = vi.fn(); + const joining1 = vi.fn(); + const leaving1 = vi.fn(); + + channel.here(here1); + channel.here(here2); + channel.joining(joining1); + channel.leaving(leaving1); + + channel.updatePresence({ + members: [{ id: 1 }], + joined: [{ id: 2 }], + left: [{ id: 3 }], + }); + + expect(here1).toHaveBeenCalledOnce(); + expect(here2).toHaveBeenCalledOnce(); + expect(joining1).toHaveBeenCalledOnce(); + expect(leaving1).toHaveBeenCalledOnce(); + }); + + test("unsubscribe clears presence callbacks", () => { + const here = vi.fn(); + const joining = vi.fn(); + const leaving = vi.fn(); + + channel.here(here); + channel.joining(joining); + channel.leaving(leaving); + + channel.unsubscribe(); + + channel.updatePresence({ + members: [{ id: 1 }], + joined: [{ id: 2 }], + left: [{ id: 3 }], + }); + + expect(here).not.toHaveBeenCalled(); + expect(joining).not.toHaveBeenCalled(); + expect(leaving).not.toHaveBeenCalled(); + }); + + test("whisper is a no-op", () => { + const result = channel.whisper("typing", { user: 1 }); + expect(result).toBe(channel); + }); + + test("inherits event listening from PollChannel", () => { + const cb = vi.fn(); + channel.listen("MessageSent", cb); + + channel.dispatch("MessageSent", { text: "hello" }); + + expect(cb).toHaveBeenCalledWith({ text: "hello" }); + }); +}); diff --git a/packages/laravel-echo/tests/connector/poll-connector.test.ts b/packages/laravel-echo/tests/connector/poll-connector.test.ts new file mode 100644 index 00000000..5bc4445e --- /dev/null +++ b/packages/laravel-echo/tests/connector/poll-connector.test.ts @@ -0,0 +1,458 @@ +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { PollChannel, PollPresenceChannel } from "../../src/channel"; +import { PollConnector } from "../../src/connector"; + +function mockFetchResponse(data: any): void { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(data), + }), + ); +} + +function mockFetchFailure(error?: Error): void { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(error ?? new Error("network error")), + ); +} + +/** + * Flush the initial poll's promise chain. The connector calls poll() + * synchronously during connect(), but the fetch response resolves + * as a microtask. Advancing by 0ms flushes it. + */ +async function flushInitialPoll(): Promise { + await vi.advanceTimersByTimeAsync(0); +} + +describe("PollConnector", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockFetchResponse({ events: [], lastEventId: "cursor-1" }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + function createConnector(overrides: Record = {}) { + return new PollConnector({ + broadcaster: "poll", + pollInterval: 5000, + withoutInterceptors: true, + ...overrides, + }); + } + + test("generates a socket ID on connect", () => { + const connector = createConnector(); + const id = connector.socketId(); + + expect(id).toBeDefined(); + expect(typeof id).toBe("string"); + expect(id!.length).toBeGreaterThan(0); + expect(id).toContain("."); + + connector.disconnect(); + }); + + test("creates public channels", () => { + const connector = createConnector(); + const channel = connector.channel("orders"); + + expect(channel).toBeInstanceOf(PollChannel); + expect(channel.name).toBe("orders"); + + // Same instance on subsequent calls + expect(connector.channel("orders")).toBe(channel); + + connector.disconnect(); + }); + + test("creates private channels with prefix", () => { + const connector = createConnector(); + const channel = connector.privateChannel("orders"); + + expect(channel).toBeInstanceOf(PollChannel); + expect(channel.name).toBe("private-orders"); + + expect(connector.privateChannel("orders")).toBe(channel); + + connector.disconnect(); + }); + + test("creates presence channels with prefix", () => { + const connector = createConnector(); + const channel = connector.presenceChannel("chat"); + + expect(channel).toBeInstanceOf(PollPresenceChannel); + expect(channel.name).toBe("presence-chat"); + + expect(connector.presenceChannel("chat")).toBe(channel); + + connector.disconnect(); + }); + + test("leave removes channel and its variants", () => { + const connector = createConnector(); + connector.channel("orders"); + connector.privateChannel("orders"); + connector.presenceChannel("orders"); + + expect(Object.keys(connector.channels)).toHaveLength(3); + + connector.leave("orders"); + + expect(Object.keys(connector.channels)).toHaveLength(0); + + connector.disconnect(); + }); + + test("leaveChannel removes a single channel", () => { + const connector = createConnector(); + connector.channel("orders"); + connector.privateChannel("orders"); + + connector.leaveChannel("orders"); + + expect(connector.channels["orders"]).toBeUndefined(); + expect(connector.channels["private-orders"]).toBeDefined(); + + connector.disconnect(); + }); + + test("starts with connecting status", () => { + const connector = createConnector(); + + expect(connector.connectionStatus()).toBe("connecting"); + + connector.disconnect(); + }); + + test("transitions to connected after first successful poll", async () => { + const connector = createConnector(); + + await flushInitialPoll(); + + expect(connector.connectionStatus()).toBe("connected"); + + connector.disconnect(); + }); + + test("fires connection status change callbacks", async () => { + const connector = createConnector(); + const cb = vi.fn(); + connector.onConnectionChange(cb); + + await flushInitialPoll(); + + expect(cb).toHaveBeenCalledWith("connected"); + + connector.disconnect(); + }); + + test("unsubscribe from connection status changes", async () => { + const connector = createConnector(); + const cb = vi.fn(); + const unsub = connector.onConnectionChange(cb); + + unsub(); + + await flushInitialPoll(); + + expect(cb).not.toHaveBeenCalled(); + + connector.disconnect(); + }); + + test("disconnect stops polling and sets disconnected", async () => { + const connector = createConnector(); + await flushInitialPoll(); + + connector.disconnect(); + + expect(connector.connectionStatus()).toBe("disconnected"); + expect(Object.keys(connector.channels)).toHaveLength(0); + }); + + test("polls the correct endpoint with channel names", async () => { + const connector = createConnector(); + connector.channel("orders"); + connector.privateChannel("users"); + + // Trigger an interval poll so the channels are included + await vi.advanceTimersByTimeAsync(5000); + + const fetchMock = vi.mocked(fetch); + const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; + const url = lastCall[0] as string; + + expect(url).toContain("/broadcasting/poll?"); + expect(url).toContain("channels%5B%5D=orders"); + expect(url).toContain("channels%5B%5D=private-users"); + + connector.disconnect(); + }); + + test("sends X-Socket-ID header", async () => { + const connector = createConnector(); + + await flushInitialPoll(); + + const fetchMock = vi.mocked(fetch); + const firstCall = fetchMock.mock.calls[0]; + const options = firstCall[1] as RequestInit; + + expect((options.headers as Record)["X-Socket-ID"]).toBe( + connector.socketId(), + ); + + connector.disconnect(); + }); + + test("sends lastEventId after first poll", async () => { + const connector = createConnector(); + connector.channel("orders"); + + // First poll resolves with cursor-1 + await flushInitialPoll(); + + // Trigger the next interval poll + await vi.advanceTimersByTimeAsync(5000); + + const fetchMock = vi.mocked(fetch); + const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; + const url = lastCall[0] as string; + + expect(url).toContain("lastEventId=cursor-1"); + + connector.disconnect(); + }); + + test("dispatches events to correct channels", async () => { + mockFetchResponse({ + events: [ + { + id: "evt-1", + channel: "orders", + event: "OrderShipped", + data: { id: 42 }, + }, + { + id: "evt-2", + channel: "private-users", + event: "UserUpdated", + data: { name: "Alice" }, + }, + ], + lastEventId: "evt-2", + }); + + const connector = createConnector(); + const ordersCb = vi.fn(); + const usersCb = vi.fn(); + + connector.channel("orders").on("OrderShipped", ordersCb); + connector.privateChannel("users").on("UserUpdated", usersCb); + + // Trigger an interval poll (channels are now registered) + await vi.advanceTimersByTimeAsync(5000); + + expect(ordersCb).toHaveBeenCalledWith({ id: 42 }); + expect(usersCb).toHaveBeenCalledWith({ name: "Alice" }); + + connector.disconnect(); + }); + + test("dispatches presence data to presence channels", async () => { + mockFetchResponse({ + events: [], + lastEventId: "cursor-1", + presence: { + "presence-chat": { + members: [{ id: 1, name: "Alice" }], + joined: [{ id: 2, name: "Bob" }], + left: [], + }, + }, + }); + + const connector = createConnector(); + const hereCb = vi.fn(); + const joiningCb = vi.fn(); + + connector.presenceChannel("chat").here(hereCb); + connector.presenceChannel("chat").joining(joiningCb); + + // Trigger interval poll with presence data + await vi.advanceTimersByTimeAsync(5000); + + expect(hereCb).toHaveBeenCalledWith([{ id: 1, name: "Alice" }]); + expect(joiningCb).toHaveBeenCalledWith({ id: 2, name: "Bob" }); + + connector.disconnect(); + }); + + test("notifies subscribed callbacks on first successful poll", async () => { + const connector = createConnector(); + const cb = vi.fn(); + + connector.channel("orders").subscribed(cb); + + await flushInitialPoll(); + + expect(cb).toHaveBeenCalledOnce(); + + connector.disconnect(); + }); + + test("transitions to reconnecting when poll fails after connected", async () => { + const connector = createConnector(); + connector.channel("orders"); + + // First poll succeeds + await flushInitialPoll(); + expect(connector.connectionStatus()).toBe("connected"); + + // Next poll fails + mockFetchFailure(); + await vi.advanceTimersByTimeAsync(5000); + + expect(connector.connectionStatus()).toBe("reconnecting"); + + connector.disconnect(); + }); + + test("transitions to failed when poll fails before ever connecting", async () => { + mockFetchFailure(); + + const connector = createConnector(); + await flushInitialPoll(); + + expect(connector.connectionStatus()).toBe("failed"); + + connector.disconnect(); + }); + + test("notifies error callbacks on poll failure", async () => { + mockFetchFailure(new Error("network error")); + + const connector = createConnector(); + const cb = vi.fn(); + connector.channel("orders").error(cb); + + await flushInitialPoll(); + + expect(cb).toHaveBeenCalledWith(expect.any(Error)); + + connector.disconnect(); + }); + + test("uses custom pollEndpoint", async () => { + const connector = createConnector({ + pollEndpoint: "/api/custom-poll", + }); + + await flushInitialPoll(); + + const fetchMock = vi.mocked(fetch); + const url = fetchMock.mock.calls[0][0] as string; + + expect(url).toContain("/api/custom-poll?"); + + connector.disconnect(); + }); + + test("uses custom pollInterval", async () => { + const connector = createConnector({ pollInterval: 2000 }); + connector.channel("orders"); + + await flushInitialPoll(); + + const fetchMock = vi.mocked(fetch); + const callCount = fetchMock.mock.calls.length; + + // Advance by 2 seconds (custom interval) + await vi.advanceTimersByTimeAsync(2000); + + expect(fetchMock.mock.calls.length).toBe(callCount + 1); + + connector.disconnect(); + }); + + test("recovers to connected after reconnecting", async () => { + const connector = createConnector(); + connector.channel("orders"); + + // Connect successfully + await flushInitialPoll(); + expect(connector.connectionStatus()).toBe("connected"); + + // Fail + mockFetchFailure(); + await vi.advanceTimersByTimeAsync(5000); + expect(connector.connectionStatus()).toBe("reconnecting"); + + // Recover + mockFetchResponse({ events: [], lastEventId: "cursor-2" }); + await vi.advanceTimersByTimeAsync(5000); + expect(connector.connectionStatus()).toBe("connected"); + + connector.disconnect(); + }); + + test("listen() convenience method works", async () => { + mockFetchResponse({ + events: [ + { + id: "evt-1", + channel: "orders", + event: "OrderShipped", + data: { id: 1 }, + }, + ], + lastEventId: "evt-1", + }); + + const connector = createConnector(); + const cb = vi.fn(); + + connector.listen("orders", ".OrderShipped", cb); + + // Trigger interval poll + await vi.advanceTimersByTimeAsync(5000); + + expect(cb).toHaveBeenCalledWith({ id: 1 }); + + connector.disconnect(); + }); + + test("prevents overlapping poll requests", async () => { + // Make fetch hang (never resolve) + vi.stubGlobal( + "fetch", + vi.fn().mockReturnValue(new Promise(() => {})), + ); + + const connector = createConnector(); + + // Advance past several intervals while the first poll is still pending + vi.advanceTimersByTime(15000); + + // fetch should only have been called once (the initial poll) + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); + + connector.disconnect(); + }); +}); diff --git a/packages/laravel-echo/tests/echo.test.ts b/packages/laravel-echo/tests/echo.test.ts index 3e9f244e..653b2e61 100644 --- a/packages/laravel-echo/tests/echo.test.ts +++ b/packages/laravel-echo/tests/echo.test.ts @@ -22,6 +22,10 @@ describe("Echo", () => { }), ).not.toThrow("Broadcaster string socket.io is not supported."); + expect( + () => new Echo({ broadcaster: "poll", withoutInterceptors: true }), + ).not.toThrow("Broadcaster string poll is not supported."); + expect( () => new Echo({ broadcaster: "null", withoutInterceptors: true }), ).not.toThrow("Broadcaster string null is not supported."); From 126c27732e500d777d1c15d78117cd544c79afd4 Mon Sep 17 00:00:00 2001 From: Mathias Grimm Date: Thu, 26 Feb 2026 13:33:17 -0300 Subject: [PATCH 2/2] wip --- .../src/channel/poll-presence-channel.ts | 50 +++++--- .../src/connector/poll-connector.ts | 27 ++-- .../channel/poll-presence-channel.test.ts | 115 ++++++++++++++---- .../tests/connector/poll-connector.test.ts | 91 +++++++++----- 4 files changed, 196 insertions(+), 87 deletions(-) diff --git a/packages/laravel-echo/src/channel/poll-presence-channel.ts b/packages/laravel-echo/src/channel/poll-presence-channel.ts index 16cbeca7..26d56576 100644 --- a/packages/laravel-echo/src/channel/poll-presence-channel.ts +++ b/packages/laravel-echo/src/channel/poll-presence-channel.ts @@ -23,6 +23,15 @@ export class PollPresenceChannel */ private leavingCallbacks: Set = new Set(); + /** + * Track known member IDs for client-side join/leave detection. + * + * The server returns only the current members list. Each client + * diffs against its own known members to detect joins and leaves, + * ensuring every client sees every change regardless of poll timing. + */ + private knownMembers: Map = new Map(); + /** * Register a callback to be called anytime the member list changes. */ @@ -56,25 +65,37 @@ export class PollPresenceChannel /** * Update presence data from the poll response. + * + * The server returns only { members: [...] }. This method computes + * joined/left by diffing against the client's own known members. */ - updatePresence(data: { - members: any[]; - joined: any[]; - left: any[]; - }): void { - if (data.members) { - this.hereCallbacks.forEach((cb) => cb(data.members)); - } - if (data.joined) { - data.joined.forEach((member: any) => { + updatePresence(data: { members: any[] }): void { + const currentMembers = data.members ?? []; + const currentIds = new Set( + currentMembers.map((m: any) => m.user_id), + ); + + // Detect newly joined members + for (const member of currentMembers) { + if (!this.knownMembers.has(member.user_id)) { this.joiningCallbacks.forEach((cb) => cb(member)); - }); + } } - if (data.left) { - data.left.forEach((member: any) => { + + // Detect members who left + for (const [id, member] of this.knownMembers) { + if (!currentIds.has(id)) { this.leavingCallbacks.forEach((cb) => cb(member)); - }); + } } + + // Update known members + this.knownMembers = new Map( + currentMembers.map((m: any) => [m.user_id, m]), + ); + + // Fire here() with current member list + this.hereCallbacks.forEach((cb) => cb(currentMembers)); } /** @@ -85,5 +106,6 @@ export class PollPresenceChannel this.hereCallbacks.clear(); this.joiningCallbacks.clear(); this.leavingCallbacks.clear(); + this.knownMembers.clear(); } } diff --git a/packages/laravel-echo/src/connector/poll-connector.ts b/packages/laravel-echo/src/connector/poll-connector.ts index fd9c4cd5..b4daedcd 100644 --- a/packages/laravel-echo/src/connector/poll-connector.ts +++ b/packages/laravel-echo/src/connector/poll-connector.ts @@ -230,7 +230,7 @@ export class PollConnector extends Connector< const channelNames = Object.keys(this.channels); - if (channelNames.length === 0 && this.lastEventId !== null) { + if (channelNames.length === 0) { return; } @@ -240,26 +240,25 @@ export class PollConnector extends Connector< const endpoint = this.options.pollEndpoint ?? "/broadcasting/poll"; - const params = new URLSearchParams(); - channelNames.forEach((name) => { - params.append("channels[]", name); - }); + const body: Record = { + channels: channelNames, + }; if (this.lastEventId) { - params.append("lastEventId", this.lastEventId); + body.lastEventId = this.lastEventId; } - const url = `${endpoint}?${params.toString()}`; - const headers: Record = { - Accept: "application/json", + "Accept": "application/json", + "Content-Type": "application/json", "X-Socket-ID": this._socketId, ...this.options.auth.headers, }; - const response = await fetch(url, { - method: "GET", + const response = await fetch(endpoint, { + method: "POST", headers, credentials: "same-origin", + body: JSON.stringify(body), }); if (!response.ok) { @@ -295,11 +294,7 @@ export class PollConnector extends Connector< const ch = this.channels[channelName]; if (ch && ch instanceof PollPresenceChannel) { ch.updatePresence( - presenceData as { - members: any[]; - joined: any[]; - left: any[]; - }, + presenceData as { members: any[] }, ); } } diff --git a/packages/laravel-echo/tests/channel/poll-presence-channel.test.ts b/packages/laravel-echo/tests/channel/poll-presence-channel.test.ts index 74035581..e686df5c 100644 --- a/packages/laravel-echo/tests/channel/poll-presence-channel.test.ts +++ b/packages/laravel-echo/tests/channel/poll-presence-channel.test.ts @@ -18,43 +18,68 @@ describe("PollPresenceChannel", () => { channel.here(cb); channel.updatePresence({ - members: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }], - joined: [], - left: [], + members: [ + { user_id: 1, user_info: { name: "Alice" } }, + { user_id: 2, user_info: { name: "Bob" } }, + ], }); expect(cb).toHaveBeenCalledWith([ - { id: 1, name: "Alice" }, - { id: 2, name: "Bob" }, + { user_id: 1, user_info: { name: "Alice" } }, + { user_id: 2, user_info: { name: "Bob" } }, ]); }); - test("joining() callbacks fire for each joined member", () => { + test("joining() callbacks fire for newly seen members", () => { const cb = vi.fn(); channel.joining(cb); + // First update — all members are "new" channel.updatePresence({ - members: [], - joined: [{ id: 3, name: "Charlie" }, { id: 4, name: "Diana" }], - left: [], + members: [{ user_id: 1, user_info: {} }], }); - expect(cb).toHaveBeenCalledTimes(2); - expect(cb).toHaveBeenCalledWith({ id: 3, name: "Charlie" }); - expect(cb).toHaveBeenCalledWith({ id: 4, name: "Diana" }); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith({ user_id: 1, user_info: {} }); + + cb.mockClear(); + + // Second update — user 2 joins + channel.updatePresence({ + members: [ + { user_id: 1, user_info: {} }, + { user_id: 2, user_info: {} }, + ], + }); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith({ user_id: 2, user_info: {} }); }); - test("leaving() callbacks fire for each left member", () => { + test("leaving() callbacks fire when a member disappears", () => { const cb = vi.fn(); channel.leaving(cb); + // First update — establish known members channel.updatePresence({ - members: [], - joined: [], - left: [{ id: 1, name: "Alice" }], + members: [ + { user_id: 1, user_info: { name: "Alice" } }, + { user_id: 2, user_info: { name: "Bob" } }, + ], }); - expect(cb).toHaveBeenCalledWith({ id: 1, name: "Alice" }); + expect(cb).not.toHaveBeenCalled(); + + // Second update — user 1 is gone + channel.updatePresence({ + members: [{ user_id: 2, user_info: { name: "Bob" } }], + }); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith({ + user_id: 1, + user_info: { name: "Alice" }, + }); }); test("multiple callbacks can be registered for each event", () => { @@ -68,19 +93,25 @@ describe("PollPresenceChannel", () => { channel.joining(joining1); channel.leaving(leaving1); + // First update — user 1 joins channel.updatePresence({ - members: [{ id: 1 }], - joined: [{ id: 2 }], - left: [{ id: 3 }], + members: [{ user_id: 1, user_info: {} }], }); expect(here1).toHaveBeenCalledOnce(); expect(here2).toHaveBeenCalledOnce(); expect(joining1).toHaveBeenCalledOnce(); + + // Second update — user 1 leaves, user 2 joins + channel.updatePresence({ + members: [{ user_id: 2, user_info: {} }], + }); + expect(leaving1).toHaveBeenCalledOnce(); + expect(joining1).toHaveBeenCalledTimes(2); }); - test("unsubscribe clears presence callbacks", () => { + test("unsubscribe clears presence callbacks and known members", () => { const here = vi.fn(); const joining = vi.fn(); const leaving = vi.fn(); @@ -92,9 +123,7 @@ describe("PollPresenceChannel", () => { channel.unsubscribe(); channel.updatePresence({ - members: [{ id: 1 }], - joined: [{ id: 2 }], - left: [{ id: 3 }], + members: [{ user_id: 1, user_info: {} }], }); expect(here).not.toHaveBeenCalled(); @@ -102,6 +131,44 @@ describe("PollPresenceChannel", () => { expect(leaving).not.toHaveBeenCalled(); }); + test("same member in consecutive updates does not trigger joining again", () => { + const joining = vi.fn(); + channel.joining(joining); + + channel.updatePresence({ + members: [{ user_id: 1, user_info: {} }], + }); + + joining.mockClear(); + + channel.updatePresence({ + members: [{ user_id: 1, user_info: {} }], + }); + + expect(joining).not.toHaveBeenCalled(); + }); + + test("every client sees leaving independently", () => { + const leaving = vi.fn(); + channel.leaving(leaving); + + // Establish members + channel.updatePresence({ + members: [ + { user_id: 1, user_info: {} }, + { user_id: 2, user_info: {} }, + ], + }); + + // User 2 disappears — this client sees leaving + channel.updatePresence({ + members: [{ user_id: 1, user_info: {} }], + }); + + expect(leaving).toHaveBeenCalledTimes(1); + expect(leaving).toHaveBeenCalledWith({ user_id: 2, user_info: {} }); + }); + test("whisper is a no-op", () => { const result = channel.whisper("typing", { user: 1 }); expect(result).toBe(channel); diff --git a/packages/laravel-echo/tests/connector/poll-connector.test.ts b/packages/laravel-echo/tests/connector/poll-connector.test.ts index 5bc4445e..fad638ed 100644 --- a/packages/laravel-echo/tests/connector/poll-connector.test.ts +++ b/packages/laravel-echo/tests/connector/poll-connector.test.ts @@ -27,11 +27,10 @@ function mockFetchFailure(error?: Error): void { } /** - * Flush the initial poll's promise chain. The connector calls poll() - * synchronously during connect(), but the fetch response resolves - * as a microtask. Advancing by 0ms flushes it. + * Flush the poll's promise chain. The connector's poll() is async + * and the fetch response resolves as a microtask. */ -async function flushInitialPoll(): Promise { +async function flushPoll(): Promise { await vi.advanceTimersByTimeAsync(0); } @@ -142,8 +141,10 @@ describe("PollConnector", () => { test("transitions to connected after first successful poll", async () => { const connector = createConnector(); + connector.channel("orders"); - await flushInitialPoll(); + // Trigger first interval poll (channels now registered) + await vi.advanceTimersByTimeAsync(5000); expect(connector.connectionStatus()).toBe("connected"); @@ -152,10 +153,11 @@ describe("PollConnector", () => { test("fires connection status change callbacks", async () => { const connector = createConnector(); + connector.channel("orders"); const cb = vi.fn(); connector.onConnectionChange(cb); - await flushInitialPoll(); + await vi.advanceTimersByTimeAsync(5000); expect(cb).toHaveBeenCalledWith("connected"); @@ -164,12 +166,13 @@ describe("PollConnector", () => { test("unsubscribe from connection status changes", async () => { const connector = createConnector(); + connector.channel("orders"); const cb = vi.fn(); const unsub = connector.onConnectionChange(cb); unsub(); - await flushInitialPoll(); + await vi.advanceTimersByTimeAsync(5000); expect(cb).not.toHaveBeenCalled(); @@ -178,7 +181,8 @@ describe("PollConnector", () => { test("disconnect stops polling and sets disconnected", async () => { const connector = createConnector(); - await flushInitialPoll(); + connector.channel("orders"); + await vi.advanceTimersByTimeAsync(5000); connector.disconnect(); @@ -186,7 +190,7 @@ describe("PollConnector", () => { expect(Object.keys(connector.channels)).toHaveLength(0); }); - test("polls the correct endpoint with channel names", async () => { + test("polls the correct endpoint with channel names via POST", async () => { const connector = createConnector(); connector.channel("orders"); connector.privateChannel("users"); @@ -197,22 +201,27 @@ describe("PollConnector", () => { const fetchMock = vi.mocked(fetch); const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; const url = lastCall[0] as string; + const options = lastCall[1] as RequestInit; + + expect(url).toBe("/broadcasting/poll"); + expect(options.method).toBe("POST"); - expect(url).toContain("/broadcasting/poll?"); - expect(url).toContain("channels%5B%5D=orders"); - expect(url).toContain("channels%5B%5D=private-users"); + const body = JSON.parse(options.body as string); + expect(body.channels).toContain("orders"); + expect(body.channels).toContain("private-users"); connector.disconnect(); }); test("sends X-Socket-ID header", async () => { const connector = createConnector(); + connector.channel("orders"); - await flushInitialPoll(); + await vi.advanceTimersByTimeAsync(5000); const fetchMock = vi.mocked(fetch); - const firstCall = fetchMock.mock.calls[0]; - const options = firstCall[1] as RequestInit; + const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; + const options = lastCall[1] as RequestInit; expect((options.headers as Record)["X-Socket-ID"]).toBe( connector.socketId(), @@ -226,16 +235,17 @@ describe("PollConnector", () => { connector.channel("orders"); // First poll resolves with cursor-1 - await flushInitialPoll(); + await vi.advanceTimersByTimeAsync(5000); // Trigger the next interval poll await vi.advanceTimersByTimeAsync(5000); const fetchMock = vi.mocked(fetch); const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; - const url = lastCall[0] as string; + const options = lastCall[1] as RequestInit; + const body = JSON.parse(options.body as string); - expect(url).toContain("lastEventId=cursor-1"); + expect(body.lastEventId).toBe("cursor-1"); connector.disconnect(); }); @@ -276,14 +286,16 @@ describe("PollConnector", () => { }); test("dispatches presence data to presence channels", async () => { + // Server returns only members — client computes joined/left mockFetchResponse({ events: [], lastEventId: "cursor-1", presence: { "presence-chat": { - members: [{ id: 1, name: "Alice" }], - joined: [{ id: 2, name: "Bob" }], - left: [], + members: [ + { user_id: 1, user_info: { name: "Alice" } }, + { user_id: 2, user_info: { name: "Bob" } }, + ], }, }, }); @@ -298,8 +310,14 @@ describe("PollConnector", () => { // Trigger interval poll with presence data await vi.advanceTimersByTimeAsync(5000); - expect(hereCb).toHaveBeenCalledWith([{ id: 1, name: "Alice" }]); - expect(joiningCb).toHaveBeenCalledWith({ id: 2, name: "Bob" }); + expect(hereCb).toHaveBeenCalledWith([ + { user_id: 1, user_info: { name: "Alice" } }, + { user_id: 2, user_info: { name: "Bob" } }, + ]); + // Both are new to the client, so joining fires for each + expect(joiningCb).toHaveBeenCalledTimes(2); + expect(joiningCb).toHaveBeenCalledWith({ user_id: 1, user_info: { name: "Alice" } }); + expect(joiningCb).toHaveBeenCalledWith({ user_id: 2, user_info: { name: "Bob" } }); connector.disconnect(); }); @@ -310,7 +328,7 @@ describe("PollConnector", () => { connector.channel("orders").subscribed(cb); - await flushInitialPoll(); + await vi.advanceTimersByTimeAsync(5000); expect(cb).toHaveBeenCalledOnce(); @@ -322,7 +340,7 @@ describe("PollConnector", () => { connector.channel("orders"); // First poll succeeds - await flushInitialPoll(); + await vi.advanceTimersByTimeAsync(5000); expect(connector.connectionStatus()).toBe("connected"); // Next poll fails @@ -338,7 +356,9 @@ describe("PollConnector", () => { mockFetchFailure(); const connector = createConnector(); - await flushInitialPoll(); + connector.channel("orders"); + + await vi.advanceTimersByTimeAsync(5000); expect(connector.connectionStatus()).toBe("failed"); @@ -352,7 +372,7 @@ describe("PollConnector", () => { const cb = vi.fn(); connector.channel("orders").error(cb); - await flushInitialPoll(); + await vi.advanceTimersByTimeAsync(5000); expect(cb).toHaveBeenCalledWith(expect.any(Error)); @@ -363,13 +383,14 @@ describe("PollConnector", () => { const connector = createConnector({ pollEndpoint: "/api/custom-poll", }); + connector.channel("orders"); - await flushInitialPoll(); + await vi.advanceTimersByTimeAsync(5000); const fetchMock = vi.mocked(fetch); const url = fetchMock.mock.calls[0][0] as string; - expect(url).toContain("/api/custom-poll?"); + expect(url).toBe("/api/custom-poll"); connector.disconnect(); }); @@ -378,7 +399,7 @@ describe("PollConnector", () => { const connector = createConnector({ pollInterval: 2000 }); connector.channel("orders"); - await flushInitialPoll(); + await flushPoll(); const fetchMock = vi.mocked(fetch); const callCount = fetchMock.mock.calls.length; @@ -396,7 +417,7 @@ describe("PollConnector", () => { connector.channel("orders"); // Connect successfully - await flushInitialPoll(); + await vi.advanceTimersByTimeAsync(5000); expect(connector.connectionStatus()).toBe("connected"); // Fail @@ -446,11 +467,15 @@ describe("PollConnector", () => { ); const connector = createConnector(); + connector.channel("orders"); + + // Trigger first poll + vi.advanceTimersByTime(5000); - // Advance past several intervals while the first poll is still pending + // Advance past several more intervals while the first poll is still pending vi.advanceTimersByTime(15000); - // fetch should only have been called once (the initial poll) + // fetch should only have been called once expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); connector.disconnect();