fix(perplexityService, directoryTemplateService): CORS bypass — Node.js https replaces activeWindow.fetch for all Perplexity streaming paths

Perplexity's API stopped including Access-Control-Allow-Origin headers
for app://obsidian.md, so Electron's Chromium renderer began blocking
every streaming response body — even on successful 200 OK requests.
Every Perplexity query that used streaming was silently producing nothing.

Both streaming call sites now use https.request from node:https instead
of activeWindow.fetch. Node.js routes through the OS network stack and
has no CORS constraint at all. The resulting IncomingMessage stream is
wrapped in a Web ReadableStream<Uint8Array> and passed to the existing
SSE parsing machinery unchanged — idle-timeout, chunk accumulation,
citations, images, and AbortController signal handling all preserved.

Non-streaming requests (Obsidian's request() function) were never
affected and are untouched.

perplexityService.ts:
- Added import * as https from 'node:https' + import type { IncomingMessage }
- Replaced activeWindow.fetch in queryPerplexity streaming branch with
  https.request → IncomingMessage → ReadableStream<Uint8Array> bridge

directoryTemplateService.ts:
- Same imports
- Replaced activeWindow.fetch in streamPerplexityToFile with same bridge
- AbortController.signal threaded into https.request signal option so
  ceiling-timer and user-cancel abort behavior is fully preserved

Bumps to 0.3.1 — manifest.json, package.json, versions.json updated.

Files changed:
- src/services/perplexityService.ts
- src/services/directoryTemplateService.ts
- manifest.json
- package.json
- versions.json
- changelog/2026-07-06_01.md
- changelog/releases/0.3.1.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mpstaton 2026-07-06 14:41:12 -05:00
parent f946973894
commit 01897c0903
7 changed files with 209 additions and 34 deletions

View file

@ -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<Uint8Array>` 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<Uint8Array>`. 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<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` — 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.

View file

@ -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<Uint8Array>` 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.

View file

@ -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",

View file

@ -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": {

View file

@ -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<IncomingMessage>((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<Uint8Array>({
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;

View file

@ -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<IncomingMessage>((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<Uint8Array>({
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...');

View file

@ -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"
}