CORS block fixed — Perplexity streaming now routes through Node.js and never touches Chromium's CORS enforcement
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.
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. TheactiveWindow.fetchcall was replaced with ahttps.requestPromise that resolves to a Node.jsIncomingMessagestream. That stream is wrapped in a WebReadableStream<Uint8Array>and passed to the existinghandleStreamingResponsemethod unchanged — no changes to the SSE parsing, idle-timeout logic, or citations handling.src/services/directoryTemplateService.ts— thestreamPerplexityToFilefunction used by the directory-template runner. Same technique:https.request→IncomingMessage→ReadableStream<Uint8Array>. TheAbortControllersignal (used by the ceiling timer and the user-cancel path) is passed directly tohttps.requestvia itssignaloption, 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<Uint8Array>. 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— addedimport * as https from 'node:https'andimport type { IncomingMessage } from 'node:http'; replacedactiveWindow.fetchin the streaming branch ofqueryPerplexitywith ahttps.request→ WebReadableStreambridge.src/services/directoryTemplateService.ts— same imports; replacedactiveWindow.fetchinstreamPerplexityToFilewith the same bridge pattern;AbortController.signalthreaded through tohttps.request'ssignaloption to preserve ceiling-timer and user-cancel behavior.