Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
fix: resolve race condition in Puppeteer authentication
- Add detailed logging for browser launch and navigation timing
- Log response headers to detect Cloudflare blocking
- Log page content to diagnose HTML vs JSON responses
- Improve debugging capabilities in Docker environments
- Update changelog and bump version to 0.8.1
  • Loading branch information
chrisle committed Sep 30, 2025
commit 0a7963c7afa963e9a2c4f566037498cde6745bd7
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# @retconned/kickjs

## 0.8.1

### Patch Changes

- fix: resolve race condition in Puppeteer authentication
- Add detailed logging for browser launch and navigation timing
- Log response headers to detect Cloudflare blocking
- Log page content to diagnose HTML vs JSON responses
- Improve debugging capabilities in Docker environments

## 0.8.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@retconned/kick-js",
"version": "0.8.0",
"version": "0.8.1",
"description": "A typescript bot interface for kick.com",
"keywords": [
"Kick.com",
Expand Down
42 changes: 38 additions & 4 deletions src/apis/private/channelData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,43 @@ import { setupPuppeteer } from "./utils";
* @returns Promise resolving to channel info or null if not found
* @throws Error if channel doesn't exist or request is forbidden
*/
export const getChannelData = async (
const getChannelDataImpl = async (
channel: string,
puppeteerOptions?: LaunchOptions,
): Promise<KickChannelInfo | null> => {
let browser: Browser | undefined;
try {
console.log(`[Puppeteer] Starting browser for channel: ${channel}`);
console.log(`[Puppeteer] Options:`, JSON.stringify(puppeteerOptions));

const startTime = Date.now();
const setup = await setupPuppeteer(puppeteerOptions);
browser = setup.browser;
const page = setup.page;
console.log(`[Puppeteer] Browser launched in ${Date.now() - startTime}ms`);

const response = await page.goto(
`https://kick.com/api/v2/channels/${channel}`,
);
const url = `https://kick.com/api/v2/channels/${channel}`;
console.log(`[Puppeteer] Navigating to: ${url}`);

const navStartTime = Date.now();
const response = await page.goto(url, {
waitUntil: 'networkidle0',
timeout: 30000
});
console.log(`[Puppeteer] Navigation completed in ${Date.now() - navStartTime}ms`);
console.log(`[Puppeteer] Response status: ${response?.status()}`);
console.log(`[Puppeteer] Response headers:`, response?.headers());

if (response && response.status() === 404) {
throw new Error(`Channel '${channel}' does not exist on Kick.com`);
}

if (response && response.status() === 403) {
console.log(`[Puppeteer] 403 Forbidden - Cloudflare may be blocking`);
throw new Error("Request forbidden");
}

console.log(`[Puppeteer] Extracting page content...`);
const jsonContent: KickChannelInfo = await page.evaluate(
(): KickChannelInfo => {
const bodyText = document.querySelector("body")!.innerText.trim();
Expand All @@ -48,6 +63,7 @@ export const getChannelData = async (
bodyText.includes("can't find the page") ||
bodyText.includes("Something went wrong")
) {
console.error(`[Puppeteer] Received HTML instead of JSON: ${bodyText.substring(0, 200)}`);
throw new Error(
"Channel not found - received error page instead of JSON",
);
Expand All @@ -56,13 +72,15 @@ export const getChannelData = async (
try {
return JSON.parse(bodyText) as KickChannelInfo;
} catch {
console.error(`[Puppeteer] Failed to parse JSON: ${bodyText.substring(0, 100)}`);
throw new Error(
`Invalid JSON response: ${bodyText.substring(0, 100)}...`,
);
}
},
);

console.log(`[Puppeteer] Successfully retrieved channel data for: ${channel}`);
return jsonContent;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
Expand All @@ -81,3 +99,19 @@ export const getChannelData = async (
}
}
};

/**
* Mutable reference to the getChannelData function.
* Can be replaced at runtime to override the default implementation.
*/
export let getChannelData = getChannelDataImpl;

/**
* Replace the getChannelData function with a custom implementation
* @param customImpl - Custom function to use instead of the default Puppeteer implementation
*/
export const setChannelDataProvider = (
customImpl: (channel: string, puppeteerOptions?: LaunchOptions) => Promise<KickChannelInfo | null>
) => {
getChannelData = customImpl;
};
2 changes: 1 addition & 1 deletion src/apis/private/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
export { authentication } from "./authentication";

// Channel Data Scraping
export { getChannelData } from "./channelData";
export { getChannelData, setChannelDataProvider } from "./channelData";

// Video Data Scraping
export { getVideoData } from "./videoData";
Expand Down
39 changes: 31 additions & 8 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export { getPublicKey } from "../apis/public/publicKey";
export { introspectToken, getUsers } from "../apis/public/users";

// Re-export private APIs for direct use
export { getChannelData } from "../apis/private/channelData";
export { getChannelData, setChannelDataProvider } from "../apis/private/channelData";
export { getVideoData } from "../apis/private/videoData";
export {
deleteMessage as deleteMessageDirect,
Expand Down Expand Up @@ -446,19 +446,42 @@ export const createClient = (
}
});

socket.on("open", () => {
if (mergedOptions.logger) {
console.debug(`Connected to channel: ${channelToJoin}`);
}
emitter.emit("ready", getUser());
});

// Set up error handler first (outside promise to persist)
socket.on("error", (error) => {
if (mergedOptions.logger) {
console.error("WebSocket error:", error);
}
emitter.emit("error", error);
});

// Wait for WebSocket to open before resolving initialize()
// This prevents race conditions where 'ready' event fires before listeners are registered
await new Promise<void>((resolve, reject) => {
if (!socket) {
reject(new Error('WebSocket not initialized'));
return;
}

const timeout = setTimeout(() => {
reject(new Error('WebSocket connection timeout'));
}, 30000);

socket.on("open", () => {
clearTimeout(timeout);
if (mergedOptions.logger) {
console.debug(`Connected to channel: ${channelToJoin}`);
}
// Emit 'ready' event for backwards compatibility
emitter.emit("ready", getUser());
// Resolve the promise so login() can complete
resolve();
});

socket.once("error", (error) => {
clearTimeout(timeout);
reject(error);
});
});
} catch (error) {
if (mergedOptions.logger) {
console.error("Initialization failed:", error);
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { MessageData } from "./types/events.js";
import type { KickClient } from "./types/client.js";
import { getFeaturedLivestreams } from "./apis/private/featuredLivestreams";
import type { GetFeaturedLivestreamsOptions } from "./apis/private/featuredLivestreams";
import { setChannelDataProvider } from "./apis/private/channelData";

export { createClient, getFeaturedLivestreams };
export { createClient, getFeaturedLivestreams, setChannelDataProvider };
export type { MessageData, GetFeaturedLivestreamsOptions, KickClient };