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..26d56576 --- /dev/null +++ b/packages/laravel-echo/src/channel/poll-presence-channel.ts @@ -0,0 +1,111 @@ +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(); + + /** + * 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. + */ + 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. + * + * The server returns only { members: [...] }. This method computes + * joined/left by diffing against the client's own known members. + */ + 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)); + } + } + + // 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)); + } + + /** + * Unsubscribe from a channel. + */ + unsubscribe(): void { + super.unsubscribe(); + this.hereCallbacks.clear(); + this.joiningCallbacks.clear(); + this.leavingCallbacks.clear(); + this.knownMembers.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..b4daedcd --- /dev/null +++ b/packages/laravel-echo/src/connector/poll-connector.ts @@ -0,0 +1,316 @@ +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) { + return; + } + + this.polling = true; + + try { + const endpoint = + this.options.pollEndpoint ?? "/broadcasting/poll"; + + const body: Record = { + channels: channelNames, + }; + if (this.lastEventId) { + body.lastEventId = this.lastEventId; + } + + const headers: Record = { + "Accept": "application/json", + "Content-Type": "application/json", + "X-Socket-ID": this._socketId, + ...this.options.auth.headers, + }; + + const response = await fetch(endpoint, { + method: "POST", + headers, + credentials: "same-origin", + body: JSON.stringify(body), + }); + + 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[] }, + ); + } + } + } + } 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..e686df5c --- /dev/null +++ b/packages/laravel-echo/tests/channel/poll-presence-channel.test.ts @@ -0,0 +1,185 @@ +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: [ + { user_id: 1, user_info: { name: "Alice" } }, + { user_id: 2, user_info: { name: "Bob" } }, + ], + }); + + expect(cb).toHaveBeenCalledWith([ + { user_id: 1, user_info: { name: "Alice" } }, + { user_id: 2, user_info: { name: "Bob" } }, + ]); + }); + + test("joining() callbacks fire for newly seen members", () => { + const cb = vi.fn(); + channel.joining(cb); + + // First update — all members are "new" + channel.updatePresence({ + members: [{ user_id: 1, user_info: {} }], + }); + + 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 when a member disappears", () => { + const cb = vi.fn(); + channel.leaving(cb); + + // First update — establish known members + channel.updatePresence({ + members: [ + { user_id: 1, user_info: { name: "Alice" } }, + { user_id: 2, user_info: { name: "Bob" } }, + ], + }); + + 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", () => { + 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); + + // First update — user 1 joins + channel.updatePresence({ + 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 and known members", () => { + 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: [{ user_id: 1, user_info: {} }], + }); + + expect(here).not.toHaveBeenCalled(); + expect(joining).not.toHaveBeenCalled(); + 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); + }); + + 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..fad638ed --- /dev/null +++ b/packages/laravel-echo/tests/connector/poll-connector.test.ts @@ -0,0 +1,483 @@ +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 poll's promise chain. The connector's poll() is async + * and the fetch response resolves as a microtask. + */ +async function flushPoll(): 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(); + connector.channel("orders"); + + // Trigger first interval poll (channels now registered) + await vi.advanceTimersByTimeAsync(5000); + + expect(connector.connectionStatus()).toBe("connected"); + + connector.disconnect(); + }); + + test("fires connection status change callbacks", async () => { + const connector = createConnector(); + connector.channel("orders"); + const cb = vi.fn(); + connector.onConnectionChange(cb); + + await vi.advanceTimersByTimeAsync(5000); + + expect(cb).toHaveBeenCalledWith("connected"); + + connector.disconnect(); + }); + + test("unsubscribe from connection status changes", async () => { + const connector = createConnector(); + connector.channel("orders"); + const cb = vi.fn(); + const unsub = connector.onConnectionChange(cb); + + unsub(); + + await vi.advanceTimersByTimeAsync(5000); + + expect(cb).not.toHaveBeenCalled(); + + connector.disconnect(); + }); + + test("disconnect stops polling and sets disconnected", async () => { + const connector = createConnector(); + connector.channel("orders"); + await vi.advanceTimersByTimeAsync(5000); + + connector.disconnect(); + + expect(connector.connectionStatus()).toBe("disconnected"); + expect(Object.keys(connector.channels)).toHaveLength(0); + }); + + test("polls the correct endpoint with channel names via POST", 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; + const options = lastCall[1] as RequestInit; + + expect(url).toBe("/broadcasting/poll"); + expect(options.method).toBe("POST"); + + 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 vi.advanceTimersByTimeAsync(5000); + + const fetchMock = vi.mocked(fetch); + 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(), + ); + + connector.disconnect(); + }); + + test("sends lastEventId after first poll", async () => { + const connector = createConnector(); + connector.channel("orders"); + + // First poll resolves with cursor-1 + 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 options = lastCall[1] as RequestInit; + const body = JSON.parse(options.body as string); + + expect(body.lastEventId).toBe("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 () => { + // Server returns only members — client computes joined/left + mockFetchResponse({ + events: [], + lastEventId: "cursor-1", + presence: { + "presence-chat": { + members: [ + { user_id: 1, user_info: { name: "Alice" } }, + { user_id: 2, user_info: { name: "Bob" } }, + ], + }, + }, + }); + + 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([ + { 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(); + }); + + test("notifies subscribed callbacks on first successful poll", async () => { + const connector = createConnector(); + const cb = vi.fn(); + + connector.channel("orders").subscribed(cb); + + await vi.advanceTimersByTimeAsync(5000); + + 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 vi.advanceTimersByTimeAsync(5000); + 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(); + connector.channel("orders"); + + await vi.advanceTimersByTimeAsync(5000); + + 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 vi.advanceTimersByTimeAsync(5000); + + expect(cb).toHaveBeenCalledWith(expect.any(Error)); + + connector.disconnect(); + }); + + test("uses custom pollEndpoint", async () => { + const connector = createConnector({ + pollEndpoint: "/api/custom-poll", + }); + connector.channel("orders"); + + await vi.advanceTimersByTimeAsync(5000); + + const fetchMock = vi.mocked(fetch); + const url = fetchMock.mock.calls[0][0] as string; + + expect(url).toBe("/api/custom-poll"); + + connector.disconnect(); + }); + + test("uses custom pollInterval", async () => { + const connector = createConnector({ pollInterval: 2000 }); + connector.channel("orders"); + + await flushPoll(); + + 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 vi.advanceTimersByTimeAsync(5000); + 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(); + connector.channel("orders"); + + // Trigger first poll + vi.advanceTimersByTime(5000); + + // Advance past several more intervals while the first poll is still pending + vi.advanceTimersByTime(15000); + + // fetch should only have been called once + 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.");