fix: use Node.js https module for SSE streaming
Both fetch and XHR are blocked by Electron's CORS/CSP restrictions from the renderer process. Node.js https bypasses this entirely, the same way EmbedSearch uses fs and path. Incremental SSE parsing via data events. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15324,8 +15324,8 @@ var init_hub = __esm({
|
|||||||
* @param {string} request
|
* @param {string} request
|
||||||
* @returns {Promise<FileResponse | undefined>}
|
* @returns {Promise<FileResponse | undefined>}
|
||||||
*/
|
*/
|
||||||
async match(request) {
|
async match(request2) {
|
||||||
let filePath = import_path2.default.join(this.path, request);
|
let filePath = import_path2.default.join(this.path, request2);
|
||||||
let file = new FileResponse(filePath);
|
let file = new FileResponse(filePath);
|
||||||
if (file.exists) {
|
if (file.exists) {
|
||||||
return file;
|
return file;
|
||||||
@@ -15339,9 +15339,9 @@ var init_hub = __esm({
|
|||||||
* @param {Response|FileResponse} response
|
* @param {Response|FileResponse} response
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async put(request, response) {
|
async put(request2, response) {
|
||||||
const buffer = Buffer.from(await response.arrayBuffer());
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
let outputPath = import_path2.default.join(this.path, request);
|
let outputPath = import_path2.default.join(this.path, request2);
|
||||||
try {
|
try {
|
||||||
await import_fs2.default.promises.mkdir(import_path2.default.dirname(outputPath), { recursive: true });
|
await import_fs2.default.promises.mkdir(import_path2.default.dirname(outputPath), { recursive: true });
|
||||||
await import_fs2.default.promises.writeFile(outputPath, buffer);
|
await import_fs2.default.promises.writeFile(outputPath, buffer);
|
||||||
@@ -32717,6 +32717,7 @@ var HybridSearch = class {
|
|||||||
|
|
||||||
// src/ClaudeClient.ts
|
// src/ClaudeClient.ts
|
||||||
var import_obsidian2 = require("obsidian");
|
var import_obsidian2 = require("obsidian");
|
||||||
|
var https = __toESM(require("https"));
|
||||||
var ClaudeClient = class {
|
var ClaudeClient = class {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.baseUrl = "https://api.anthropic.com/v1/messages";
|
this.baseUrl = "https://api.anthropic.com/v1/messages";
|
||||||
@@ -32729,9 +32730,9 @@ var ClaudeClient = class {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Stream a chat completion via XHR + SSE, yielding text chunks as they arrive.
|
* Stream a chat completion via Node.js https + SSE, yielding text chunks as they arrive.
|
||||||
* Uses XHR instead of fetch because Obsidian patches the global fetch in a way
|
* Uses the Node.js https module (available in Obsidian's Electron renderer via Node integration)
|
||||||
* that buffers the full response, breaking streaming.
|
* to bypass Electron's CORS/CSP restrictions that block fetch and XHR to external APIs.
|
||||||
*/
|
*/
|
||||||
async *streamChat(messages, options) {
|
async *streamChat(messages, options) {
|
||||||
const queue = [];
|
const queue = [];
|
||||||
@@ -32747,17 +32748,39 @@ var ClaudeClient = class {
|
|||||||
wakeup?.();
|
wakeup?.();
|
||||||
wakeup = null;
|
wakeup = null;
|
||||||
};
|
};
|
||||||
const xhr = new XMLHttpRequest();
|
const body = JSON.stringify({
|
||||||
xhr.open("POST", this.baseUrl, true);
|
model: options.model,
|
||||||
for (const [k, v] of Object.entries(this.headers(options.apiKey))) {
|
max_tokens: options.maxTokens ?? 8192,
|
||||||
xhr.setRequestHeader(k, v);
|
system: options.systemPrompt,
|
||||||
|
messages,
|
||||||
|
stream: true
|
||||||
|
});
|
||||||
|
const req = https.request(
|
||||||
|
{
|
||||||
|
hostname: "api.anthropic.com",
|
||||||
|
path: "/v1/messages",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...this.headers(options.apiKey),
|
||||||
|
"content-length": Buffer.byteLength(body).toString()
|
||||||
}
|
}
|
||||||
let linesCursor = 0;
|
},
|
||||||
const parseSse = (allDone) => {
|
(res) => {
|
||||||
const lines = xhr.responseText.split("\n");
|
if ((res.statusCode ?? 0) >= 400) {
|
||||||
const limit = allDone ? lines.length : lines.length - 1;
|
let errBody = "";
|
||||||
for (let i = linesCursor; i < limit; i++) {
|
res.on("data", (d) => errBody += d.toString());
|
||||||
const line = lines[i];
|
res.on("end", () => {
|
||||||
|
push({ type: "error", error: `API Error ${res.statusCode}: ${errBody}` });
|
||||||
|
finish();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let buf = "";
|
||||||
|
res.on("data", (chunk) => {
|
||||||
|
buf += chunk.toString();
|
||||||
|
const lines = buf.split("\n");
|
||||||
|
buf = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
if (!line.startsWith("data: "))
|
if (!line.startsWith("data: "))
|
||||||
continue;
|
continue;
|
||||||
const data = line.slice(6).trim();
|
const data = line.slice(6).trim();
|
||||||
@@ -32771,32 +32794,22 @@ var ClaudeClient = class {
|
|||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
linesCursor = limit;
|
});
|
||||||
};
|
res.on("end", () => {
|
||||||
xhr.onprogress = () => parseSse(false);
|
finish();
|
||||||
xhr.onload = () => {
|
});
|
||||||
if (xhr.status >= 400) {
|
res.on("error", (e) => {
|
||||||
push({ type: "error", error: `API Error ${xhr.status}: ${xhr.responseText}` });
|
push({ type: "error", error: e.message });
|
||||||
} else {
|
finish();
|
||||||
parseSse(true);
|
});
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
req.on("error", (e) => {
|
||||||
|
push({ type: "error", error: e.message });
|
||||||
finish();
|
finish();
|
||||||
};
|
});
|
||||||
xhr.onerror = () => {
|
req.write(body);
|
||||||
push({ type: "error", error: "Network error" });
|
req.end();
|
||||||
finish();
|
|
||||||
};
|
|
||||||
xhr.ontimeout = () => {
|
|
||||||
push({ type: "error", error: "Request timed out" });
|
|
||||||
finish();
|
|
||||||
};
|
|
||||||
xhr.send(JSON.stringify({
|
|
||||||
model: options.model,
|
|
||||||
max_tokens: options.maxTokens ?? 8192,
|
|
||||||
system: options.systemPrompt,
|
|
||||||
messages,
|
|
||||||
stream: true
|
|
||||||
}));
|
|
||||||
while (true) {
|
while (true) {
|
||||||
while (queue.length)
|
while (queue.length)
|
||||||
yield queue.shift();
|
yield queue.shift();
|
||||||
|
|||||||
+41
-33
@@ -1,4 +1,5 @@
|
|||||||
import { requestUrl } from "obsidian";
|
import { requestUrl } from "obsidian";
|
||||||
|
import * as https from "https";
|
||||||
|
|
||||||
export interface ClaudeMessage {
|
export interface ClaudeMessage {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
@@ -31,9 +32,9 @@ export class ClaudeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream a chat completion via XHR + SSE, yielding text chunks as they arrive.
|
* Stream a chat completion via Node.js https + SSE, yielding text chunks as they arrive.
|
||||||
* Uses XHR instead of fetch because Obsidian patches the global fetch in a way
|
* Uses the Node.js https module (available in Obsidian's Electron renderer via Node integration)
|
||||||
* that buffers the full response, breaking streaming.
|
* to bypass Electron's CORS/CSP restrictions that block fetch and XHR to external APIs.
|
||||||
*/
|
*/
|
||||||
async *streamChat(
|
async *streamChat(
|
||||||
messages: ClaudeMessage[],
|
messages: ClaudeMessage[],
|
||||||
@@ -46,19 +47,38 @@ export class ClaudeClient {
|
|||||||
const push = (c: ClaudeStreamChunk) => { queue.push(c); wakeup?.(); wakeup = null; };
|
const push = (c: ClaudeStreamChunk) => { queue.push(c); wakeup?.(); wakeup = null; };
|
||||||
const finish = () => { done = true; wakeup?.(); wakeup = null; };
|
const finish = () => { done = true; wakeup?.(); wakeup = null; };
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const body = JSON.stringify({
|
||||||
xhr.open("POST", this.baseUrl, true);
|
model: options.model,
|
||||||
for (const [k, v] of Object.entries(this.headers(options.apiKey))) {
|
max_tokens: options.maxTokens ?? 8192,
|
||||||
xhr.setRequestHeader(k, v);
|
system: options.systemPrompt,
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = https.request(
|
||||||
|
{
|
||||||
|
hostname: "api.anthropic.com",
|
||||||
|
path: "/v1/messages",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...this.headers(options.apiKey),
|
||||||
|
"content-length": Buffer.byteLength(body).toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
if ((res.statusCode ?? 0) >= 400) {
|
||||||
|
let errBody = "";
|
||||||
|
res.on("data", (d: Buffer) => errBody += d.toString());
|
||||||
|
res.on("end", () => { push({ type: "error", error: `API Error ${res.statusCode}: ${errBody}` }); finish(); });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse SSE lines from xhr.responseText; linesCursor avoids reprocessing old lines.
|
let buf = "";
|
||||||
let linesCursor = 0;
|
res.on("data", (chunk: Buffer) => {
|
||||||
const parseSse = (allDone: boolean) => {
|
buf += chunk.toString();
|
||||||
const lines = xhr.responseText.split("\n");
|
const lines = buf.split("\n");
|
||||||
const limit = allDone ? lines.length : lines.length - 1; // skip last (may be partial)
|
buf = lines.pop() ?? ""; // keep partial last line
|
||||||
for (let i = linesCursor; i < limit; i++) {
|
for (const line of lines) {
|
||||||
const line = lines[i];
|
|
||||||
if (!line.startsWith("data: ")) continue;
|
if (!line.startsWith("data: ")) continue;
|
||||||
const data = line.slice(6).trim();
|
const data = line.slice(6).trim();
|
||||||
if (data === "[DONE]") return;
|
if (data === "[DONE]") return;
|
||||||
@@ -69,28 +89,16 @@ export class ClaudeClient {
|
|||||||
}
|
}
|
||||||
} catch { /* skip malformed lines */ }
|
} catch { /* skip malformed lines */ }
|
||||||
}
|
}
|
||||||
linesCursor = limit;
|
});
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onprogress = () => parseSse(false);
|
res.on("end", () => { finish(); });
|
||||||
xhr.onload = () => {
|
res.on("error", (e: Error) => { push({ type: "error", error: e.message }); finish(); });
|
||||||
if (xhr.status >= 400) {
|
|
||||||
push({ type: "error", error: `API Error ${xhr.status}: ${xhr.responseText}` });
|
|
||||||
} else {
|
|
||||||
parseSse(true);
|
|
||||||
}
|
}
|
||||||
finish();
|
);
|
||||||
};
|
|
||||||
xhr.onerror = () => { push({ type: "error", error: "Network error" }); finish(); };
|
|
||||||
xhr.ontimeout = () => { push({ type: "error", error: "Request timed out" }); finish(); };
|
|
||||||
|
|
||||||
xhr.send(JSON.stringify({
|
req.on("error", (e: Error) => { push({ type: "error", error: e.message }); finish(); });
|
||||||
model: options.model,
|
req.write(body);
|
||||||
max_tokens: options.maxTokens ?? 8192,
|
req.end();
|
||||||
system: options.systemPrompt,
|
|
||||||
messages,
|
|
||||||
stream: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
while (queue.length) yield queue.shift()!;
|
while (queue.length) yield queue.shift()!;
|
||||||
|
|||||||
Reference in New Issue
Block a user