diff --git a/changelog/2026-07-06_01.md b/changelog/2026-07-06_01.md new file mode 100644 index 0000000..85283d4 --- /dev/null +++ b/changelog/2026-07-06_01.md @@ -0,0 +1,48 @@ +--- +date_created: 2026-07-06 +date_modified: 2026-07-06 +title: "CORS block fixed — Perplexity streaming now routes through Node.js and never touches Chromium's CORS enforcement" +lede: "Perplexity streaming stopped working when Perplexity's API began enforcing CORS headers that block requests originating from app://obsidian.md. Both streaming call sites — the modal query flow and the directory-template batch runner — were using activeWindow.fetch, which runs in Electron's Chromium renderer and is fully CORS-constrained. Both are now replaced with Node.js https.request, which routes through the OS network stack and is never subject to CORS enforcement." +publish: true +authors: + - Michael Staton +augmented_with: + - Claude Code on Claude Sonnet 4.6 +files_changed: + - src/services/perplexityService.ts + - src/services/directoryTemplateService.ts +--- + +## Why care? + +If you clicked **Ask Perplexity** or ran a directory template and got a CORS error — "Access to fetch at 'https://api.perplexity.ai/chat/completions' from origin 'app://obsidian.md' has been blocked" — this is the fix. Streaming was broken for every Perplexity query, regardless of model or template. Non-streaming requests were unaffected because they already used Obsidian's `request()` function, which bypasses CORS by routing through a different layer. + +The root cause: Perplexity's API stopped including the `Access-Control-Allow-Origin` header that Chromium's renderer requires before it will hand a response body to the calling code. The response was actually arriving and returning `200 OK` — Chromium was reading the headers, seeing no CORS allowance for `app://obsidian.md`, and then blocking the caller from reading the body. The underlying request was succeeding; the plugin just couldn't see any of it. + +## What's new? + +Both streaming code paths now use `https.request` from Node.js's built-in `node:https` module instead of `activeWindow.fetch`. Node.js routes requests through the OS network stack — TLS negotiation, DNS resolution, TCP — entirely outside Chromium's process. CORS enforcement is a Chromium concept; it has no meaning at the OS socket layer. + +The esbuild config already marks all Node.js built-ins as external (`...builtins` in the external array, `platform: 'node'`), so `import * as https from 'node:https'` compiles to `require('https')` in the output bundle and is available at runtime in Electron's renderer. + +- **`src/services/perplexityService.ts`** — the modal query path. The `activeWindow.fetch` call was replaced with a `https.request` Promise that resolves to a Node.js `IncomingMessage` stream. That stream is wrapped in a Web `ReadableStream` and passed to the existing `handleStreamingResponse` method unchanged — no changes to the SSE parsing, idle-timeout logic, or citations handling. + +- **`src/services/directoryTemplateService.ts`** — the `streamPerplexityToFile` function used by the directory-template runner. Same technique: `https.request` → `IncomingMessage` → `ReadableStream`. The `AbortController` signal (used by the ceiling timer and the user-cancel path) is passed directly to `https.request` via its `signal` option, so abort behavior is preserved. + +## How the fix works + +The key insight is that Obsidian's Electron renderer has full Node.js integration — `require('https')` is available exactly the same as in a plain Node.js process. The reason the plugin was using `activeWindow.fetch` in the first place was that Obsidian's `requestUrl()` API buffers the entire response before returning, which makes SSE streaming impossible. Node.js `https.request` gives you the raw `IncomingMessage` stream — a readable Node.js stream — which can be consumed chunk-by-chunk exactly like a Fetch API `ReadableStream`. + +The bridge between the two worlds is a one-way conversion: each `Buffer` chunk arriving on the Node.js stream is enqueued into a Web `ReadableStream`. The existing SSE parser, idle-timeout, and citations handling never see the difference. + +``` +Before: activeWindow.fetch → Chromium renderer → CORS check → BLOCKED +After: https.request → Node.js network stack → OS socket → no CORS +``` + +Non-streaming requests (the toggle-off path in the Perplexity modal) continue to use Obsidian's `request()` function, which already routes through the main process and was never affected. + +## Files touched + +- `src/services/perplexityService.ts` — added `import * as https from 'node:https'` and `import type { IncomingMessage } from 'node:http'`; replaced `activeWindow.fetch` in the streaming branch of `queryPerplexity` with a `https.request` → Web `ReadableStream` bridge. +- `src/services/directoryTemplateService.ts` — same imports; replaced `activeWindow.fetch` in `streamPerplexityToFile` with the same bridge pattern; `AbortController.signal` threaded through to `https.request`'s `signal` option to preserve ceiling-timer and user-cancel behavior. diff --git a/changelog/releases/0.3.1.md b/changelog/releases/0.3.1.md new file mode 100644 index 0000000..b5917d1 --- /dev/null +++ b/changelog/releases/0.3.1.md @@ -0,0 +1,64 @@ +--- +title: "Perplexed 0.3.1 — Perplexity streaming restored after CORS enforcement change" +lede: "Perplexity streaming broke when Perplexity's API stopped including the CORS headers that Electron's Chromium renderer requires. Every streaming query — modal and directory templates alike — was silently failing with a 200 OK response that Obsidian couldn't read. 0.3.1 routes all streaming through Node.js https instead of fetch, bypassing CORS enforcement entirely. Non-streaming requests were never affected." +date_authored_initial_draft: 2026-07-06 +date_authored_current_draft: 2026-07-06 +date_first_published: 2026-07-06 +date_last_updated: 2026-07-06 +at_semantic_version: 0.3.1 +status: Published +category: Release +tags: + - Release-Narrative + - CORS + - Streaming + - Node-https + - Perplexity-API + - Bug-Fix + - Electron +authors: + - Michael Staton +augmented_with: + - Claude Code on Claude Sonnet 4.6 +release_tag: "0.3.1" +prior_release_tag: "0.3.0" +--- + +## Why care? + +If you upgraded to Obsidian recently or noticed that **Ask Perplexity** and your directory templates silently stopped producing output — no streamed text, no error message in the note, just a CORS error in the developer console — this release fixes it. + +Perplexity's API changed its CORS headers. Electron's Chromium renderer enforces CORS strictly: if a server's response doesn't include `Access-Control-Allow-Origin` for the calling origin, Chromium reads the response headers and then refuses to hand the body to the calling code. The request was completing successfully on Perplexity's end (`200 OK`), but the plugin received nothing because Chromium blocked access to the body. + +Every streaming query was affected: the quick **Ask Perplexity** modal, the deep-research modal, and the directory-template batch runner (`streamPerplexityToFile`). Non-streaming requests (the toggle-off path) were never affected — they already used Obsidian's `request()` function, which routes through the main process and has no CORS constraint. + +0.3.1 routes all streaming through Node.js's built-in `https` module. Node.js operates below the Chromium layer entirely — it's a direct OS socket connection. CORS is a browser concept; it doesn't exist at the socket level. + +## What changed? + +Two files, minimal surface area: + +**`src/services/perplexityService.ts`** — the modal query path. `activeWindow.fetch` replaced with `https.request` from `node:https`. The resulting Node.js `IncomingMessage` stream is wrapped in a Web `ReadableStream` and handed to the existing `handleStreamingResponse` method. The SSE parsing, idle-timeout, chunk accumulation, citations, and images handling are untouched. + +**`src/services/directoryTemplateService.ts`** — the `streamPerplexityToFile` function. Same approach. The existing `AbortController` (used by the ceiling timer and user-cancel path) is threaded directly into `https.request`'s `signal` option — abort behavior is fully preserved. + +``` +Before: activeWindow.fetch → Chromium renderer → CORS enforcement → body blocked +After: https.request → Node.js network stack → OS socket → no CORS +``` + +No changes to the Perplexity API payload, model list, streaming protocol, or any vault-side behavior. This is a pure transport-layer swap. + +## Upgrade notes + +No action required. Reload the plugin after upgrading (Settings → Community Plugins → Perplexed → toggle off, then on) and streaming will work immediately. There are no vault-template changes, no new cft-block keys, no settings-pane changes. + +If you are running Perplexed via a development symlink (`ln -s` from the repo into your vault's `.obsidian/plugins/`), run `pnpm build` from the `perplexed/` directory and then reload the plugin. + +## Compatibility + +`minAppVersion: 1.8.10` — unchanged. Desktop-only — unchanged. No new external dependencies; the fix uses `node:https` from Node.js's standard library, which is already available in Obsidian's Electron runtime. All 0.3.0 features (three analyst-grade templates, per-template timeout and max-tokens overrides, rendering-discipline partials) are fully intact. + +## Engineering changelog + +- [`changelog/2026-07-06_01.md`](../2026-07-06_01.md) — the full diagnosis and implementation details. diff --git a/manifest.json b/manifest.json index f3064f5..1f05722 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "perplexed", "name": "Perplexed", - "version": "0.3.0", + "version": "0.3.1", "minAppVersion": "1.8.10", "description": "Generate source-cited research content from Perplexity, Anthropic Claude, Google Gemini, Perplexica (now Vane), or local LM Studio — directly into your notes.", "author": "The Lossless Group", diff --git a/package.json b/package.json index 141f6f3..155adca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "perplexed", - "version": "0.3.0", + "version": "0.3.1", "description": "Generate source-cited research content through prompts and templates. From Perplexity, Anthropic Claude, Google Gemini, Perplexica (now Vane), or local LM Studio — directly into your notes.", "main": "main.js", "scripts": { diff --git a/src/services/directoryTemplateService.ts b/src/services/directoryTemplateService.ts index b694633..56c4e85 100644 --- a/src/services/directoryTemplateService.ts +++ b/src/services/directoryTemplateService.ts @@ -1,3 +1,5 @@ +import * as https from 'node:https'; +import type { IncomingMessage } from 'node:http'; import type { App } from 'obsidian'; import { normalizePath, Notice, parseYaml, stringifyYaml, TFile } from 'obsidian'; @@ -535,19 +537,49 @@ async function streamPerplexityToFile( ? activeWindow.setTimeout(() => controller.abort(), timeouts.ceilingMs) : null; + // Use Node.js https to bypass CORS from the Obsidian renderer origin + // (app://obsidian.md). activeWindow.fetch is blocked by Perplexity's + // CORS policy; Node.js routes through the OS network stack instead. let response: Response; try { - response = await activeWindow.fetch(endpoint, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'Accept': 'text/event-stream', - }, - body: JSON.stringify(payload), - signal: controller.signal, - cache: 'no-store', + const endpointUrl = new URL(endpoint); + const nodeStream = await new Promise((resolve, reject) => { + const req = https.request( + { + hostname: endpointUrl.hostname, + path: endpointUrl.pathname + endpointUrl.search, + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + }, + signal: controller.signal, + }, + (res) => { + if (res.statusCode !== undefined && res.statusCode >= 400) { + let body = ''; + res.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + res.on('end', () => { + reject(new Error(`Perplexity HTTP ${String(res.statusCode)} — ${body}`)); + }); + return; + } + resolve(res); + } + ); + req.on('error', reject); + req.write(JSON.stringify(payload)); + req.end(); }); + const webStream = new ReadableStream({ + start(ctrl) { + nodeStream.on('data', (chunk: Buffer) => { ctrl.enqueue(chunk); }); + nodeStream.on('end', () => { ctrl.close(); }); + nodeStream.on('error', (err: Error) => { ctrl.error(err); }); + }, + }); + response = { ok: true, body: webStream } as unknown as Response; } catch (err) { if (ceilingTimer !== null) activeWindow.clearTimeout(ceilingTimer); throw err; diff --git a/src/services/perplexityService.ts b/src/services/perplexityService.ts index 1455d9d..3a335ca 100644 --- a/src/services/perplexityService.ts +++ b/src/services/perplexityService.ts @@ -1,3 +1,5 @@ +import * as https from 'node:https'; +import type { IncomingMessage } from 'node:http'; import type { Editor} from 'obsidian'; import { Notice, request } from 'obsidian'; import type { PromptsService } from './promptsService'; @@ -519,30 +521,58 @@ export class PerplexityService { try { if (useStreaming) { - console.debug('🔄 Making streaming API request...'); - // Streaming uses activeWindow.fetch because Obsidian's - // requestUrl does not support reading SSE / chunked - // response bodies — it buffers the whole response. - const response = await activeWindow.fetch(this.settings.perplexityEndpoint, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.settings.perplexityApiKey}`, - 'Content-Type': 'application/json', - 'Accept': 'text/event-stream' - }, - body: JSON.stringify(payload), - cache: 'no-store' + console.debug('🔄 Making streaming API request via Node.js https (CORS bypass)...'); + // activeWindow.fetch is blocked by CORS from the Obsidian renderer + // origin (app://obsidian.md). Node.js https routes through the OS + // network stack and is never subject to Chromium CORS enforcement. + const endpointUrl = new URL(this.settings.perplexityEndpoint); + const nodeStream = await new Promise((resolve, reject) => { + const req = https.request( + { + hostname: endpointUrl.hostname, + path: endpointUrl.pathname + endpointUrl.search, + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.settings.perplexityApiKey}`, + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + }, + }, + (res) => { + if (res.statusCode !== undefined && res.statusCode >= 400) { + let body = ''; + res.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + res.on('end', () => { + reject(new Error(`HTTP error! status: ${res.statusCode} — ${body}`)); + }); + return; + } + resolve(res); + } + ); + req.on('error', reject); + req.write(JSON.stringify(payload)); + req.end(); }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + // Wrap Node.js IncomingMessage in a Web ReadableStream so the + // existing handleStreamingResponse method consumes it unchanged. + const webStream = new ReadableStream({ + start(controller) { + nodeStream.on('data', (chunk: Buffer) => { + controller.enqueue(chunk); + }); + nodeStream.on('end', () => { + controller.close(); + }); + nodeStream.on('error', (err: Error) => { + controller.error(err); + }); + }, + }); - if (!response.body) { - throw new Error('No response body'); - } - - console.debug('✅ Streaming response received, starting to handle...'); + const response = { ok: true, body: webStream } as unknown as Response; + console.debug('✅ Streaming response via Node.js ready, handling SSE...'); await this.handleStreamingResponse(response, editor, responseCursor, requestId, headerText, isDeepResearch); } else { console.debug('🔄 Making non-streaming API request...'); diff --git a/versions.json b/versions.json index 68f13f0..91f102a 100644 --- a/versions.json +++ b/versions.json @@ -4,5 +4,6 @@ "0.1.2": "1.8.10", "0.2.0": "1.8.10", "0.2.1": "1.8.10", - "0.3.0": "1.8.10" + "0.3.0": "1.8.10", + "0.3.1": "1.8.10" } \ No newline at end of file