mirror of
https://github.com/lossless-group/perplexed-plugin.git
synced 2026-07-22 06:49:50 +00:00
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:
parent
f946973894
commit
01897c0903
7 changed files with 209 additions and 34 deletions
48
changelog/2026-07-06_01.md
Normal file
48
changelog/2026-07-06_01.md
Normal 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.
|
||||
64
changelog/releases/0.3.1.md
Normal file
64
changelog/releases/0.3.1.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in a new issue