Simplify auth and search client, drop local JWT expiry tracking

Auth:
- Remove jwt.ts — stop decoding JWT exp claims locally
- Simplify OTP login: direct token extraction from verify response,
  remove CookieJar, BFS token extraction, session fallback, CF bypass
- Storage: drop expires field, store token-only; clear on 401/403
- Single auth path: try stored token → macOS app → OTP fallback

Search client:
- Remove Cloudflare subprocess fallback (fetchViaBunRuntime)
- Remove streamFromText, isCloudflareChallenge, BunFetchResult
- Simplify SSE fetch to single fetch() call with abort signal
- Let server validate tokens; clear cache on auth errors

Render:
- Extract shared utilities (asString, truncate) to render/util.ts
- Simplify call.ts and result.ts to import from shared module

Tests:
- Remove jwt.test.ts (module deleted)
- Add otp-flow.test.ts for email OTP authentication
- Simplify login and client tests for reduced code paths

Add debug scripts and plan documents.
This commit is contained in:
Ivan Pereira
2026-02-18 08:03:39 +00:00
parent f42531b07c
commit 501935fe1c
17 changed files with 586 additions and 772 deletions
-37
View File
@@ -1,37 +0,0 @@
const FIVE_MINUTES_MS = 5 * 60 * 1000;
const ONE_HOUR_MS = 60 * 60 * 1000;
function decodeBase64Url(input: string): string {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return Buffer.from(padded, "base64").toString("utf8");
}
/** Decode JWT payload and extract expiry as epoch ms (with 5min safety margin). Returns fallback of now+1h on decode failure. */
export function decodeJwtExpiry(token: string): number {
const fallback = Date.now() + ONE_HOUR_MS;
try {
const payload = token.split(".")[1];
if (!payload) {
return fallback;
}
const decodedPayload = decodeBase64Url(payload);
const parsed = JSON.parse(decodedPayload) as { exp?: unknown };
if (typeof parsed.exp !== "number" || !Number.isFinite(parsed.exp)) {
return fallback;
}
const expiryMs = parsed.exp * 1000 - FIVE_MINUTES_MS;
return Number.isFinite(expiryMs) ? expiryMs : fallback;
} catch {
return fallback;
}
}
/** Returns true if the token is expired (with optional buffer). */
export function isJwtExpired(token: string, bufferMs = 0): boolean {
return decodeJwtExpiry(token) <= Date.now() + bufferMs;
}
+42 -265
View File
@@ -2,23 +2,16 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { AuthError } from "../search/types.js";
import { decodeJwtExpiry, isJwtExpired } from "./jwt.js";
import { clearToken, loadToken, saveToken } from "./storage.js";
import { errorMessage } from "../render/util.js";
import { loadToken, saveToken } from "./storage.js";
const DESKTOP_AUTH_HELP =
"Install the Perplexity desktop app and sign in, or set PI_AUTH_NO_BORROW=1 to skip desktop token borrowing.";
const OTP_AUTH_HELP =
"Provide credentials via PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP, or run interactively to enter email and OTP.";
const AUTH_BASE_URL = "https://www.perplexity.ai/api/auth";
const AUTH_SESSION_URL = `${AUTH_BASE_URL}/session`;
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
const PERPLEXITY_API_VERSION = "2.18";
const SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
"__Secure-authjs.session-token",
"authjs.session-token",
] as const;
const execFileAsync = promisify(execFile);
@@ -33,165 +26,24 @@ function normalizeInput(value: string | null | undefined): string | null {
return trimmed ? trimmed : null;
}
function decodeCookieValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isLikelyPerplexityToken(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
const token = value.trim();
if (!token || token === "(null)") {
return false;
}
return token.includes(".") && token.length >= 20;
}
function buildAuthHeaders(
includeJsonContentType = false,
cookieHeader?: string,
): Record<string, string> {
function buildAuthHeaders(includeJsonContentType = false): Record<string, string> {
return {
Accept: "application/json",
...(includeJsonContentType ? { "Content-Type": "application/json" } : {}),
...(cookieHeader ? { Cookie: cookieHeader } : {}),
"User-Agent": PERPLEXITY_USER_AGENT,
"X-App-ApiVersion": PERPLEXITY_API_VERSION,
};
}
function getSetCookieValues(headers: Headers): string[] {
const withSetCookie = headers as Headers & {
getSetCookie?: () => string[];
raw?: () => Record<string, string[]>;
};
if (typeof withSetCookie.getSetCookie === "function") {
return withSetCookie.getSetCookie();
}
if (typeof withSetCookie.raw === "function") {
const raw = withSetCookie.raw();
const setCookies = raw["set-cookie"];
if (Array.isArray(setCookies)) {
return setCookies;
}
}
const single = headers.get("set-cookie");
return single ? [single] : [];
}
class CookieJar {
#cookies = new Map<string, string>();
capture(headers: Headers): void {
for (const setCookie of getSetCookieValues(headers)) {
const firstPart = setCookie.split(";")[0]?.trim();
if (!firstPart) {
continue;
}
const eqIndex = firstPart.indexOf("=");
if (eqIndex <= 0) {
continue;
}
const name = firstPart.slice(0, eqIndex).trim();
const value = firstPart.slice(eqIndex + 1).trim();
if (!name || !value) {
continue;
}
this.#cookies.set(name, decodeCookieValue(value));
}
}
toHeader(): string | undefined {
if (this.#cookies.size === 0) {
return undefined;
}
return [...this.#cookies.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
}
get(name: string): string | undefined {
return this.#cookies.get(name);
}
}
function extractTokenFromCookies(headers: Headers, jar?: CookieJar): string | null {
for (const cookieName of SESSION_COOKIE_NAMES) {
const fromJar = jar?.get(cookieName);
if (isLikelyPerplexityToken(fromJar)) {
return fromJar;
}
}
for (const setCookie of getSetCookieValues(headers)) {
for (const cookieName of SESSION_COOKIE_NAMES) {
const pattern = new RegExp(`(?:^|[;,]\\s*)${cookieName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]+)`);
const match = setCookie.match(pattern);
if (!match) {
continue;
}
const decoded = decodeCookieValue(match[1] ?? "");
if (isLikelyPerplexityToken(decoded)) {
return decoded;
}
}
}
return null;
}
function extractTokenFromPayload(payload: unknown): string | null {
if (!payload || typeof payload !== "object") {
return null;
}
const tokenKeyPattern = /(token|jwt|access)/i;
const queue: unknown[] = [payload];
const seen = new Set<object>();
while (queue.length > 0) {
const current = queue.shift();
if (!current || typeof current !== "object") {
continue;
}
if (seen.has(current)) {
continue;
}
seen.add(current);
if (Array.isArray(current)) {
for (const value of current) {
queue.push(value);
}
continue;
}
for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
if (tokenKeyPattern.test(key) && isLikelyPerplexityToken(value)) {
return value.trim();
}
if (value && typeof value === "object") {
queue.push(value);
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
const obj = payload as Record<string, unknown>;
for (const key of ["token", "accessToken", "jwt", "access_token"]) {
const value = obj[key];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return null;
}
@@ -207,16 +59,14 @@ async function loginWithEmailOtp(
email: string,
options: AuthenticateOptions,
): Promise<string> {
const cookieJar = new CookieJar();
const signal = options.signal ?? null;
const csrfResponse = await fetch(`${AUTH_BASE_URL}/csrf`, {
method: "GET",
headers: buildAuthHeaders(false, cookieJar.toHeader()),
signal: options.signal,
headers: buildAuthHeaders(),
signal,
});
cookieJar.capture(csrfResponse.headers);
if (!csrfResponse.ok) {
throw new Error(`Failed to fetch CSRF token (HTTP ${csrfResponse.status}).`);
}
@@ -229,15 +79,21 @@ async function loginWithEmailOtp(
throw new Error("CSRF token missing from Perplexity auth response.");
}
const cookies = csrfResponse.headers.getSetCookie?.() ?? [];
const cookieHeader = cookies.map((c) => c.split(";")[0]).join("; ");
const emailHeaders = buildAuthHeaders(true);
if (cookieHeader) {
emailHeaders.Cookie = cookieHeader;
}
const emailResponse = await fetch(`${AUTH_BASE_URL}/signin-email`, {
method: "POST",
headers: buildAuthHeaders(true, cookieJar.toHeader()),
headers: emailHeaders,
body: JSON.stringify({ email, csrfToken }),
signal: options.signal,
signal,
});
cookieJar.capture(emailResponse.headers);
if (!emailResponse.ok) {
throw new Error(`Failed to send OTP email (HTTP ${emailResponse.status}).`);
}
@@ -253,60 +109,29 @@ async function loginWithEmailOtp(
);
}
const otpHeaders = buildAuthHeaders(true);
if (cookieHeader) {
otpHeaders.Cookie = cookieHeader;
}
const otpResponse = await fetch(`${AUTH_BASE_URL}/signin-otp`, {
method: "POST",
headers: buildAuthHeaders(true, cookieJar.toHeader()),
headers: otpHeaders,
body: JSON.stringify({ email, otp, csrfToken }),
signal: options.signal,
signal,
});
cookieJar.capture(otpResponse.headers);
if (!otpResponse.ok) {
throw new Error(`OTP verification failed (HTTP ${otpResponse.status}).`);
}
const otpPayload = await readJsonResponse(otpResponse);
const directToken = extractTokenFromPayload(otpPayload);
if (directToken) {
return directToken;
const token = extractTokenFromPayload(otpPayload);
if (!token) {
throw new Error("Perplexity OTP response did not include a token.");
}
const cookieToken = extractTokenFromCookies(otpResponse.headers, cookieJar);
if (cookieToken) {
return cookieToken;
}
const sessionResponse = await fetch(AUTH_SESSION_URL, {
method: "GET",
headers: buildAuthHeaders(false, cookieJar.toHeader()),
signal: options.signal,
});
cookieJar.capture(sessionResponse.headers);
if (sessionResponse.ok) {
const sessionPayload = await readJsonResponse(sessionResponse);
const sessionToken = extractTokenFromPayload(sessionPayload);
if (sessionToken) {
return sessionToken;
}
const sessionCookieToken = extractTokenFromCookies(sessionResponse.headers, cookieJar);
if (sessionCookieToken) {
return sessionCookieToken;
}
}
const otpBodyHint = otpPayload ? JSON.stringify(otpPayload).slice(0, 300) : "(empty or non-JSON)";
const otpKeys =
otpPayload && typeof otpPayload === "object"
? Object.keys(otpPayload as Record<string, unknown>).join(", ")
: "N/A";
throw new Error(
`Perplexity OTP response did not include an access token. OTP keys: ${otpKeys}. OTP body preview: ${otpBodyHint}. Session status: ${sessionResponse.status}.`,
);
return token;
}
/** Extract JWT from macOS Perplexity desktop app via `defaults read`. Returns null if app not installed or not logged in. */
@@ -328,66 +153,28 @@ export async function extractFromDesktopApp(): Promise<string | null> {
}
}
/** Run MVP auth strategy: load cached → try desktop extraction → save → throw AuthError if all fail. */
/** Run auth strategy: load cached → try desktop extraction → save → throw AuthError if all fail. */
export async function authenticate(options: AuthenticateOptions = {}): Promise<string> {
const cached = await loadToken();
let sawExpiredToken = false;
if (cached) {
if (cached.expires > Date.now()) {
return cached.access;
}
if (!isJwtExpired(cached.access)) {
return cached.access;
}
sawExpiredToken = true;
await clearToken();
return cached.access;
}
const borrowDisabled = process.env.PI_AUTH_NO_BORROW === "1";
if (!borrowDisabled) {
let desktopToken: string | null;
try {
desktopToken = await extractFromDesktopApp();
} catch {
throw new AuthError(
"EXTRACTION_FAILED",
`Failed to read token from the Perplexity desktop app. Ensure the app is installed and signed in. ${DESKTOP_AUTH_HELP}`,
);
}
const desktopToken = await extractFromDesktopApp();
if (desktopToken) {
const desktopExpiry = decodeJwtExpiry(desktopToken);
if (desktopExpiry <= Date.now()) {
sawExpiredToken = true;
} else {
await saveToken({
type: "oauth",
access: desktopToken,
expires: desktopExpiry,
});
return desktopToken;
}
await saveToken({
type: "oauth",
access: desktopToken,
});
return desktopToken;
}
}
const email =
normalizeInput(process.env.PI_PERPLEXITY_EMAIL) ??
normalizeInput(await options.promptForEmail?.());
if (!email) {
if (sawExpiredToken) {
throw new AuthError(
"EXPIRED",
`Perplexity token is expired and no email was provided for OTP fallback. ${DESKTOP_AUTH_HELP} ${OTP_AUTH_HELP}`,
);
}
throw new AuthError(
"NO_TOKEN",
`Could not find a desktop token and no email was provided for OTP fallback. ${DESKTOP_AUTH_HELP} ${OTP_AUTH_HELP}`,
@@ -405,24 +192,14 @@ export async function authenticate(options: AuthenticateOptions = {}): Promise<s
throw new AuthError(
"EXTRACTION_FAILED",
`Email OTP authentication failed: ${(error as Error).message}. ${OTP_AUTH_HELP}`,
);
}
const otpExpiry = decodeJwtExpiry(otpToken);
if (otpExpiry <= Date.now()) {
throw new AuthError(
"EXPIRED",
`Perplexity returned an expired token from OTP login. Re-run login and verify OTP freshness. ${OTP_AUTH_HELP}`,
`Email OTP authentication failed: ${errorMessage(error)}. ${OTP_AUTH_HELP}`,
);
}
await saveToken({
type: "oauth",
access: otpToken,
expires: otpExpiry,
email,
});
return otpToken;
}
+5 -8
View File
@@ -1,4 +1,4 @@
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
@@ -7,17 +7,15 @@ import type { StoredToken } from "../search/types.js";
const TOKEN_PATH = join(homedir(), ".config", "pi-perplexity", "auth.json");
function isStoredToken(value: unknown): value is StoredToken {
if (!value || typeof value !== "object") {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const candidate = value as Partial<StoredToken>;
const candidate = value as Record<string, unknown>;
return (
candidate.type === "oauth" &&
typeof candidate.access === "string" &&
candidate.access.length > 0 &&
typeof candidate.expires === "number" &&
Number.isFinite(candidate.expires)
candidate.access.length > 0
);
}
@@ -39,8 +37,7 @@ export async function loadToken(): Promise<StoredToken | null> {
/** Save token to disk with 0600 permissions. Creates directory if needed. */
export async function saveToken(token: StoredToken): Promise<void> {
await mkdir(dirname(TOKEN_PATH), { recursive: true });
await writeFile(TOKEN_PATH, `${JSON.stringify(token, null, 2)}\n`, "utf8");
await chmod(TOKEN_PATH, 0o600);
await writeFile(TOKEN_PATH, `${JSON.stringify(token, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
}
/** Delete the stored token file. No-op if missing. */
+2 -1
View File
@@ -3,6 +3,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { authenticate } from "../auth/login.js";
import { clearToken } from "../auth/storage.js";
import { AuthError } from "../search/types.js";
import { errorMessage } from "../render/util.js";
const LOGIN_COMMAND_NAME = "perplexity-login";
@@ -110,7 +111,7 @@ export function registerPerplexityCommands(pi: ExtensionAPI): void {
return;
}
ctx.ui.notify(`Perplexity login failed: ${(error as Error).message || "Unknown error"}`, "error");
ctx.ui.notify(`Perplexity login failed: ${errorMessage(error)}`, "error");
}
},
});
+14 -14
View File
@@ -11,6 +11,7 @@ import { searchPerplexity } from "./search/client.js";
import { renderPerplexityCall } from "./render/call.js";
import { renderPerplexityResult } from "./render/result.js";
import { AuthError, SearchError } from "./search/types.js";
import { errorMessage } from "./render/util.js";
export default function (pi: ExtensionAPI) {
registerPerplexityCommands(pi);
@@ -49,11 +50,13 @@ export default function (pi: ExtensionAPI) {
return ctx.ui.input(label, placeholder);
};
const jwt = await authenticate({
signal,
const authOptions: Parameters<typeof authenticate>[0] = {
promptForEmail: async () => promptInput("Perplexity email", "you@example.com"),
promptForOtp: async (email) => promptInput(`Enter OTP sent to ${email}`, "123456"),
});
};
if (signal) authOptions.signal = signal;
const jwt = await authenticate(authOptions);
if (signal?.aborted) {
return {
@@ -67,15 +70,13 @@ export default function (pi: ExtensionAPI) {
details: { toolCallId },
});
const result = await searchPerplexity(
{
query: params.query,
recency: params.recency,
limit: params.limit,
},
jwt,
signal,
);
const searchParams: Parameters<typeof searchPerplexity>[0] = {
query: params.query,
};
if (params.recency) searchParams.recency = params.recency;
if (typeof params.limit === "number") searchParams.limit = params.limit;
const result = await searchPerplexity(searchParams, jwt, signal);
const formatted = formatForLLM(result, params.limit);
sourceCount =
@@ -106,7 +107,6 @@ export default function (pi: ExtensionAPI) {
if (error.code === "AUTH") {
await clearToken().catch(() => undefined);
}
return {
content: [{ type: "text", text: `Perplexity search failed: ${error.message}` }],
details: { sourceCount, queryMs },
@@ -117,7 +117,7 @@ export default function (pi: ExtensionAPI) {
content: [
{
type: "text",
text: `Perplexity search failed: ${(error as Error).message || "Unknown error"}`,
text: `Perplexity search failed: ${errorMessage(error)}`,
},
],
details: { sourceCount, queryMs },
+1 -21
View File
@@ -1,5 +1,6 @@
import type { Theme } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { asString, asPositiveNumber, truncate } from "./util.js";
interface PerplexityCallArgs {
query?: unknown;
@@ -8,27 +9,6 @@ interface PerplexityCallArgs {
}
const RECENCY_VALUES = new Set(["hour", "day", "week", "month", "year"] as const);
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function asPositiveNumber(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return undefined;
}
return value;
}
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, Math.max(1, maxLength - 1))}`;
}
export function renderPerplexityCall(args: PerplexityCallArgs, theme: Theme): Text {
const query = asString(args?.query)?.trim();
const recencyRaw = asString(args?.recency)?.trim().toLowerCase();
+6 -18
View File
@@ -1,5 +1,6 @@
import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { asString, asNumber, truncate } from "./util.js";
interface PerplexityResultDetails {
model?: unknown;
@@ -9,23 +10,6 @@ interface PerplexityResultDetails {
toolCallId?: unknown;
error?: unknown;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, Math.max(1, maxLength - 1))}`;
}
function extractTextContent(result: AgentToolResult<PerplexityResultDetails>): string | undefined {
if (!Array.isArray(result?.content)) {
return undefined;
@@ -63,7 +47,11 @@ export function renderPerplexityResult(
options: ToolRenderResultOptions,
theme: Theme,
): Text {
const details = (result?.details ?? {}) as PerplexityResultDetails;
const raw = result?.details;
const details: PerplexityResultDetails =
raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as PerplexityResultDetails)
: {};
const contentText = extractTextContent(result);
if (options?.isPartial) {
+28
View File
@@ -0,0 +1,28 @@
export function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
/** Safely extract a message from an unknown caught value. */
export function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return "Unknown error";
}
export function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
export function asPositiveNumber(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return undefined;
}
return value;
}
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, Math.max(1, maxLength - 1))}`;
}
+164 -188
View File
@@ -1,16 +1,105 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mergeEvent, readSseEvents } from "./stream.js";
import type { SearchResult, StreamEvent, WebResult } from "./types.js";
import { SearchError } from "./types.js";
import { errorMessage } from "../render/util.js";
const PERPLEXITY_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask";
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
const MAX_BUN_STDOUT_BYTES = 50 * 1024 * 1024;
const CLOUDFLARE_HINTS = ["just a moment", "cloudflare", "cf-chl", "cf-ray"];
const execFileAsync = promisify(execFile);
function streamFromText(text: string): ReadableStream<Uint8Array> {
const bytes = new TextEncoder().encode(text);
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
const MAX_BUN_STDOUT = 50 * 1024 * 1024;
/**
* Execute a Perplexity request via a Bun subprocess.
* Pi loads extensions under Node/jiti whose fetch gets Cloudflare-challenged.
* Bun's native fetch has a different TLS fingerprint that passes.
*/
async function fetchViaBunRuntime(
url: string,
headers: Record<string, string>,
body: string,
signal?: AbortSignal,
): Promise<{ status: number; bodyText: string }> {
const script = `
const c = JSON.parse(await Bun.stdin.text());
try {
const r = await fetch(c.url, { method: "POST", headers: c.headers, body: c.body });
const t = await r.text();
process.stdout.write(JSON.stringify({ s: r.status, b: t }));
} catch (e) {
process.stdout.write(JSON.stringify({ s: 0, b: String(e?.message ?? e) }));
}
`;
// Dynamic import: spawn is only needed under Node/jiti (not Bun),
// and Bun's node:child_process polyfill may not export it.
const { spawn } = await import("node:child_process");
const stdout = await new Promise<string>((resolve, reject) => {
const child = spawn("bun", ["-e", script], {
stdio: ["pipe", "pipe", "ignore"],
env: { HOME: process.env.HOME, PATH: process.env.PATH },
});
if (signal) {
const onAbort = () => child.kill();
signal.addEventListener("abort", onAbort, { once: true });
child.on("close", () => signal.removeEventListener("abort", onAbort));
}
if (!child.stdin || !child.stdout) {
reject(new Error("Failed to open subprocess pipes"));
return;
}
child.stdin.write(JSON.stringify({ url, headers, body }));
child.stdin.end();
const chunks: Buffer[] = [];
let totalLen = 0;
child.stdout.on("data", (chunk: Buffer) => {
totalLen += chunk.length;
if (totalLen <= MAX_BUN_STDOUT) {
chunks.push(chunk);
}
});
child.on("close", () => resolve(Buffer.concat(chunks).toString("utf8")));
child.on("error", reject);
});
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
throw new Error(`Bun subprocess returned invalid output: ${stdout.slice(0, 200)}`);
}
if (
!parsed ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
throw new Error("Bun subprocess response is not an object.");
}
const obj = parsed as Record<string, unknown>;
if (typeof obj.s !== "number" || typeof obj.b !== "string") {
throw new Error("Bun subprocess response missing required fields.");
}
return { status: obj.s, bodyText: obj.b };
}
export interface SearchParams {
query: string;
@@ -18,12 +107,6 @@ export interface SearchParams {
limit?: number;
}
interface BunFetchResult {
status: number;
contentType: string | null;
bodyText: string;
}
function normalizeUrl(url: string): string {
return url.trim().replace(/\/$/, "").toLowerCase();
}
@@ -104,12 +187,14 @@ function extractSources(event: StreamEvent): WebResult[] {
return dedupeSourcesByUrl(blockSources);
}
const fallbackSources: WebResult[] = (event.sources_list ?? []).map((source) => ({
name: source.title,
url: source.url,
snippet: source.snippet,
timestamp: source.date,
}));
const fallbackSources: WebResult[] = (event.sources_list ?? []).map((source) => {
const result: WebResult = {};
if (source.title !== undefined) result.name = source.title;
if (source.url !== undefined) result.url = source.url;
if (source.snippet !== undefined) result.snippet = source.snippet;
if (source.date !== undefined) result.timestamp = source.date;
return result;
});
return dedupeSourcesByUrl(fallbackSources);
}
@@ -155,30 +240,8 @@ function buildRequestHeaders(jwt: string, requestId: string): Record<string, str
};
}
function isCloudflareChallenge(status: number, contentType: string | null, bodyText: string): boolean {
if (status !== 403) {
return false;
}
const contentTypeLower = (contentType ?? "").toLowerCase();
const bodyLower = bodyText.toLowerCase();
if (!contentTypeLower.includes("text/html")) {
return false;
}
return CLOUDFLARE_HINTS.some((hint) => bodyLower.includes(hint));
}
function mapHttpError(status: number, bodyText = "", contentType: string | null = null): SearchError {
function mapHttpError(status: number): SearchError {
if (status === 401 || status === 403) {
if (isCloudflareChallenge(status, contentType, bodyText)) {
return new SearchError(
"NETWORK",
"Perplexity request was blocked by Cloudflare challenge in this runtime. Retry via Bun runtime fallback or desktop app token path.",
);
}
return new SearchError(
"AUTH",
"Perplexity rejected authentication (401/403). Sign in to Perplexity desktop app and retry.",
@@ -197,101 +260,6 @@ function mapHttpError(status: number, bodyText = "", contentType: string | null
`Perplexity request failed with HTTP ${status}. Check connectivity and retry.`,
);
}
function streamFromText(text: string): ReadableStream<Uint8Array> {
const bytes = new TextEncoder().encode(text);
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
async function fetchViaBunRuntime(
requestBody: Record<string, unknown>,
jwt: string,
requestId: string,
signal?: AbortSignal,
): Promise<BunFetchResult> {
const script = `
const endpoint = process.env.PI_PPLX_ENDPOINT;
const token = process.env.PI_PPLX_TOKEN;
const body = JSON.parse(process.env.PI_PPLX_BODY || "{}");
const requestId = process.env.PI_PPLX_REQUEST_ID || crypto.randomUUID();
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: \`Bearer \${token}\`,
"Content-Type": "application/json",
Accept: "text/event-stream",
Origin: "https://www.perplexity.ai",
Referer: "https://www.perplexity.ai/",
"User-Agent": "${PERPLEXITY_USER_AGENT}",
"X-App-ApiClient": "default",
"X-App-ApiVersion": "2.18",
"X-Perplexity-Request-Reason": "submit",
"X-Request-ID": requestId,
},
body: JSON.stringify(body),
});
const text = await response.text();
process.stdout.write(JSON.stringify({
status: response.status,
contentType: response.headers.get("content-type"),
bodyText: text,
}));
} catch (error) {
process.stdout.write(JSON.stringify({
status: 0,
contentType: null,
bodyText: String(error && error.message ? error.message : error),
}));
}
`;
const { stdout } = await execFileAsync(
"bun",
["-e", script],
{
encoding: "utf8",
maxBuffer: MAX_BUN_STDOUT_BYTES,
signal,
env: {
...process.env,
PI_PPLX_ENDPOINT: PERPLEXITY_ENDPOINT,
PI_PPLX_TOKEN: jwt,
PI_PPLX_BODY: JSON.stringify(requestBody),
PI_PPLX_REQUEST_ID: requestId,
},
},
);
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
throw new Error("Bun fallback returned non-JSON output.");
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Bun fallback returned invalid payload.");
}
const result = parsed as Partial<BunFetchResult>;
if (typeof result.status !== "number" || typeof result.bodyText !== "string") {
throw new Error("Bun fallback response missing required fields.");
}
return {
status: result.status,
contentType: typeof result.contentType === "string" ? result.contentType : null,
bodyText: result.bodyText,
};
}
/** Execute a Perplexity search: POST SSE, stream/merge events, extract answer + sources. Throws SearchError on failure. */
export async function searchPerplexity(
params: SearchParams,
@@ -302,57 +270,67 @@ export async function searchPerplexity(
const requestBody = buildRequestBody(params);
const requestHeaders = buildRequestHeaders(jwt, requestId);
let response: Response;
try {
response = await fetch(PERPLEXITY_ENDPOINT, {
method: "POST",
headers: requestHeaders,
body: JSON.stringify(requestBody),
signal,
});
} catch (error) {
if (signal?.aborted) {
throw new SearchError("NETWORK", "Perplexity request was cancelled.");
}
let eventStream: ReadableStream<Uint8Array>;
throw new SearchError(
"NETWORK",
`Could not connect to Perplexity. ${(error as Error).message || "Network failure."}`,
);
}
// Bun's native fetch passes Cloudflare; Node/jiti's fetch gets challenged.
// Use native fetch when running under Bun (tests, direct scripts),
// subprocess fallback when running under Node/jiti (pi extension runtime).
const useBunSubprocess = typeof Bun === "undefined";
let eventStream: ReadableStream<Uint8Array> | null = null;
if (!response.ok) {
let bodyText = "";
if (useBunSubprocess) {
let bunResult: { status: number; bodyText: string };
try {
bodyText = await response.text();
} catch {
bodyText = "";
}
const contentType = response.headers.get("content-type");
if (isCloudflareChallenge(response.status, contentType, bodyText)) {
let bunResult: BunFetchResult;
try {
bunResult = await fetchViaBunRuntime(requestBody, jwt, requestId, signal);
} catch (error) {
throw new SearchError(
"NETWORK",
`Perplexity request hit Cloudflare challenge and Bun fallback failed: ${(error as Error).message || "unknown error"}`,
);
bunResult = await fetchViaBunRuntime(
PERPLEXITY_ENDPOINT,
requestHeaders,
JSON.stringify(requestBody),
signal,
);
} catch (error) {
if (signal?.aborted) {
throw new SearchError("NETWORK", "Perplexity request was cancelled.");
}
if (bunResult.status !== 200) {
throw mapHttpError(bunResult.status, bunResult.bodyText, bunResult.contentType);
}
eventStream = streamFromText(bunResult.bodyText);
} else {
throw mapHttpError(response.status, bodyText, contentType);
throw new SearchError(
"NETWORK",
`Could not connect to Perplexity. ${errorMessage(error)}`,
);
}
if (bunResult.status === 0) {
throw new SearchError("NETWORK", bunResult.bodyText);
}
if (bunResult.status !== 200) {
throw mapHttpError(bunResult.status);
}
if (!bunResult.bodyText) {
throw new SearchError("STREAM", "Perplexity returned an empty response.");
}
eventStream = streamFromText(bunResult.bodyText);
} else {
let response: Response;
try {
response = await fetch(PERPLEXITY_ENDPOINT, {
method: "POST",
headers: requestHeaders,
body: JSON.stringify(requestBody),
signal: signal ?? null,
});
} catch (error) {
if (signal?.aborted) {
throw new SearchError("NETWORK", "Perplexity request was cancelled.");
}
throw new SearchError(
"NETWORK",
`Could not connect to Perplexity. ${errorMessage(error)}`,
);
}
if (!response.ok) {
throw mapHttpError(response.status);
}
if (!response.body) {
throw new SearchError("STREAM", "Perplexity returned an empty stream body.");
}
@@ -360,10 +338,6 @@ export async function searchPerplexity(
eventStream = response.body;
}
if (!eventStream) {
throw new SearchError("STREAM", "Perplexity returned no readable stream.");
}
let snapshot: StreamEvent = {};
try {
@@ -384,7 +358,7 @@ export async function searchPerplexity(
throw new SearchError(
"STREAM",
`Failed to parse Perplexity stream: ${(error as Error).message || "unknown error"}`,
`Failed to parse Perplexity stream: ${errorMessage(error)}`,
);
}
@@ -405,10 +379,12 @@ export async function searchPerplexity(
);
}
return {
const result: SearchResult = {
answer: answer || "No answer text returned by Perplexity.",
sources,
displayModel: snapshot.display_model,
uuid: snapshot.uuid,
};
if (snapshot.display_model !== undefined) result.displayModel = snapshot.display_model;
if (snapshot.uuid !== undefined) result.uuid = snapshot.uuid;
return result;
}
+13 -4
View File
@@ -1,5 +1,9 @@
import type { StreamBlock, StreamEvent } from "./types.js";
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseEventPayload(payload: string): StreamEvent | null {
const trimmed = payload.trim();
if (!trimmed || trimmed === "[DONE]") {
@@ -7,10 +11,11 @@ function parseEventPayload(payload: string): StreamEvent | null {
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object") {
const parsed: unknown = JSON.parse(trimmed);
if (!isPlainObject(parsed)) {
return null;
}
// StreamEvent fields are all optional — any plain object is a valid shape.
return parsed as StreamEvent;
} catch {
return null;
@@ -126,12 +131,16 @@ export function mergeMarkdownBlock(
(mergedChunks.length > 0 ? mergedChunks.join("") : undefined) ??
existing.answer;
return {
const result: { answer?: string; chunks?: string[]; chunk_starting_offset?: number } = {
...existing,
...incoming,
answer: mergedAnswer,
chunks: mergedChunks,
};
if (mergedAnswer !== undefined) {
result.answer = mergedAnswer;
}
return result;
}
function mergeSingleBlock(existing: StreamBlock, incoming: StreamBlock): StreamBlock {
+1 -2
View File
@@ -43,7 +43,6 @@ export interface StreamSource {
export interface StoredToken {
type: "oauth";
access: string;
expires: number;
email?: string;
}
@@ -70,7 +69,7 @@ export class SearchError extends Error {
}
}
export type AuthErrorCode = "NO_TOKEN" | "EXPIRED" | "EXTRACTION_FAILED";
export type AuthErrorCode = "NO_TOKEN" | "EXTRACTION_FAILED";
export class AuthError extends Error {
constructor(