perf(plugins): keep public requests off the backend when possible

This commit is contained in:
tianma-if committed 2026-09-04 13:35:27 +08:00
1 parent 4176d26488
commit 12fca0db5f
30 files changed
+436 -136

No files matched your search

+4 -1
View File
@@ -2,5 +2,8 @@
"name": "@edgeever/api",
"version": "0.1.3",
"private": true,
"type": "module"
"type": "module",
"dependencies": {
"@edgeever/public-network": "workspace:*"
}
}
+1 -56
View File
@@ -1,56 +1 @@
import { lookup, type LookupAddress } from 'node:dns';
import type { LookupFunction } from 'node:net';
import { request } from 'node:https';
import { createBrotliDecompress, createUnzip } from 'node:zlib';
import { isPublicAddress, validatePublicUrl } from './public-network-policy';
/** Node/Bun runtime driver. Validated DNS addresses are handed directly to the TLS connection. */
type ResolveAll = (hostname: string, options: { all: true; verbatim: true }, callback: (error: NodeJS.ErrnoException | null, addresses: LookupAddress[]) => void) => void;
export function createPublicLookup(resolve: ResolveAll = lookup): LookupFunction { return (hostname, options, callback) => {
resolve(hostname, { all: true, verbatim: true }, (error, addresses) => {
if (error) { callback(error, '', 0); return; }
if (!addresses.length || addresses.some(entry => !isPublicAddress(entry.address))) {
callback(new Error('The destination does not resolve exclusively to public addresses.'), '', 0); return;
}
const candidates = options.family ? addresses.filter(entry => entry.family === options.family) : addresses;
if (!candidates.length) { callback(new Error('No public address in the requested family.'), '', 0); return; }
if (options.all) callback(null, candidates);
else callback(null, candidates[0].address, candidates[0].family);
});
}; }
export const publicLookup = createPublicLookup();
export async function nodePublicFetch(input: string, init: RequestInit = {}): Promise<Response> {
const url = validatePublicUrl(input);
init.signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const headers = Object.fromEntries(new Headers(init.headers));
const req = request(url, {
method: init.method ?? 'GET', headers: { ...headers, 'accept-encoding': 'identity' },
lookup: publicLookup, agent: false, signal: init.signal ?? undefined,
}, incoming => {
const encoding = incoming.headers['content-encoding'];
const stream = encoding === 'gzip' || encoding === 'deflate' ? incoming.pipe(createUnzip()) : encoding === 'br' ? incoming.pipe(createBrotliDecompress()) : incoming;
if (encoding && !['identity', 'gzip', 'deflate', 'br'].includes(encoding)) { incoming.destroy(); reject(new Error('Unsupported response encoding.')); return; }
const responseHeaders = new Headers();
for (const [key, value] of Object.entries(incoming.headers)) if (value !== undefined && !['content-encoding', 'content-length', 'transfer-encoding'].includes(key)) responseHeaders.set(key, Array.isArray(value) ? value.join(', ') : value);
const iterator = stream[Symbol.asyncIterator]();
const abort = () => { incoming.destroy(); stream.destroy(); };
init.signal?.addEventListener('abort', abort, { once: true });
const cleanUp = () => { init.signal?.removeEventListener('abort', abort); };
const status = incoming.statusCode ?? 502;
if (init.method === 'HEAD' || [204, 205, 304].includes(status)) { abort(); cleanUp(); resolve(new Response(null, { status, headers: responseHeaders })); return; }
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
init.signal?.throwIfAborted(); const chunk = await iterator.next(); init.signal?.throwIfAborted();
if (chunk.done) { cleanUp(); controller.close(); } else controller.enqueue(new Uint8Array(chunk.value));
} catch (error) { cleanUp(); controller.error(error); abort(); }
},
cancel() { cleanUp(); abort(); },
});
resolve(new Response(body, { status, statusText: incoming.statusMessage, headers: responseHeaders }));
});
req.on('error', reject); req.end();
});
}
export { createPublicLookup, publicLookup, nodePublicFetch } from "@edgeever/public-network/node";
@@ -32,8 +32,8 @@ test('public transport preserves upstream status and useful headers without foll
calls++; expect(url).toBe('https://example.org/feed'); expect(init.redirect).toBe('manual'); expect(init.credentials).toBe('omit'); expect(init.headers.accept).toBe('application/rss+xml');
return new Response('moved', { status: 302, headers: { Location: 'https://127.0.0.1/private', 'Set-Cookie': 'secret=not-for-plugin', 'X-Internal': 'hidden' } });
} });
const response = await call('network/fetch', { url: 'https://example.org/feed', headers: { Accept: 'application/rss+xml' } }); const data = await response.json();
expect(response.status).toBe(200); expect(data.status).toBe(302); expect(atob(data.bodyBase64)).toBe('moved'); expect(data.headers.location).toBe('https://127.0.0.1/private'); expect(data.headers).not.toHaveProperty('set-cookie'); expect(data.headers).not.toHaveProperty('x-internal'); expect(calls).toBe(1);
const response = await call('network/fetch', { url: 'https://example.org/feed', headers: { Accept: 'application/rss+xml' } });
expect(response.status).toBe(200); expect(response.headers.get('x-edgeever-upstream-status')).toBe('302'); expect(await response.text()).toBe('moved'); expect(response.headers.get('x-edgeever-upstream-header-location')).toBe('https://127.0.0.1/private'); expect(response.headers.get('x-edgeever-upstream-header-set-cookie')).toBeNull(); expect(response.headers.get('x-edgeever-upstream-header-x-internal')).toBeNull(); expect(calls).toBe(1);
});
test('oversized and cancelled public responses fail without exposing upstream error details', async () => {
+11 -3
View File
@@ -56,9 +56,17 @@ export function registerPluginCapabilityRoutes(app: Hono<AppEnv>, dependencies:
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(PUBLIC_NETWORK_TIMEOUT_MS)]);
const response = await c.env.publicNetworkFetch(url.href, { method: input.method, headers: { ...headers, 'User-Agent': 'EdgeEver-Plugins/1.0' }, redirect: 'manual', credentials: 'omit', signal });
const bytes = await readPublicBody(response, signal);
let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte);
c.header('Cache-Control', 'no-store');
return c.json({ url: url.href, status: response.status, statusText: response.statusText, headers: publicResponseHeaders(response.headers), bodyBase64: btoa(binary) });
const outputHeaders = new Headers({
'Cache-Control': 'no-store',
'Content-Type': 'application/octet-stream',
'X-EdgeEver-Upstream-Status': String(response.status),
'X-EdgeEver-Upstream-Status-Text': encodeURIComponent(response.statusText),
});
for (const [name, value] of Object.entries(publicResponseHeaders(response.headers))) outputHeaders.set(`X-EdgeEver-Upstream-Header-${name}`, value);
const body = bytes.buffer instanceof ArrayBuffer
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
: new Uint8Array(bytes).buffer;
return new Response(body, { headers: outputHeaders });
} catch { return apiError(c, 'plugin_network_failed', 'Public request failed, was blocked, exceeded its size limit, or timed out.', 502); }
});
}
+9 -57
View File
@@ -1,57 +1,9 @@
import ipaddr from 'ipaddr.js';
export const PUBLIC_NETWORK_MAX_BYTES = 2_000_000;
export const PUBLIC_NETWORK_TIMEOUT_MS = 20_000;
const REQUEST_HEADERS = new Set(['accept', 'accept-language', 'if-none-match', 'if-modified-since', 'range']);
const RESPONSE_HEADERS = new Set(['content-type', 'cache-control', 'etag', 'last-modified', 'location', 'retry-after', 'content-range', 'accept-ranges']);
export function isPublicAddress(address: string): boolean {
try { return ipaddr.process(address).range() === 'unicast'; } catch { return false; }
}
export function validatePublicUrl(input: string): URL {
const url = new URL(input);
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (url.protocol !== 'https:' || (url.port && url.port !== '443') || url.username || url.password || url.hash) throw new Error('Only public HTTPS URLs on port 443 without credentials or fragments are supported.');
// Reject IP literals as well as local/special-use names. DNS is checked at connection time by the transport.
if (ipaddr.isValid(hostname) || !hostname.includes('.') || hostname.endsWith('.') || /(?:^|\.)(localhost|local|internal|test|invalid|example|onion|home|lan|arpa)$/.test(hostname)) throw new Error('A public Internet hostname is required.');
return url;
}
export function publicRequestHeaders(input: Record<string, string>): Record<string, string> {
const result: Record<string, string> = {};
if (Object.keys(input).length > 10) throw new Error('Too many request headers.');
for (const [key, value] of Object.entries(input)) {
const name = key.toLowerCase();
if (!REQUEST_HEADERS.has(name) || /[\r\n\0]/.test(value)) throw new Error('Unsupported public request header.');
result[name] = value;
}
return result;
}
export function publicResponseHeaders(headers: Headers): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of headers) if (RESPONSE_HEADERS.has(key.toLowerCase())) result[key.toLowerCase()] = value;
return result;
}
export async function readPublicBody(response: Response, signal: AbortSignal): Promise<Uint8Array> {
const reader = response.body?.getReader();
if (!reader) return new Uint8Array();
const chunks: Uint8Array[] = []; let size = 0;
const cancel = () => { void reader.cancel().catch(() => undefined); };
signal.addEventListener('abort', cancel, { once: true });
try {
signal.throwIfAborted();
while (true) {
const { done, value } = await reader.read(); signal.throwIfAborted();
if (done) break;
size += value.byteLength;
if (size > PUBLIC_NETWORK_MAX_BYTES) throw new Error('Public response exceeds the size limit.');
chunks.push(value);
}
const bytes = new Uint8Array(size); let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
return bytes;
} finally { signal.removeEventListener('abort', cancel); await reader.cancel().catch(() => undefined); }
}
export {
PUBLIC_NETWORK_MAX_BYTES,
PUBLIC_NETWORK_TIMEOUT_MS,
isPublicAddress,
validatePublicUrl,
publicRequestHeaders,
publicResponseHeaders,
readPublicBody,
} from "@edgeever/public-network/policy";
+1
View File
@@ -17,6 +17,7 @@
"electron-builder": "^26.0.12"
},
"dependencies": {
"@edgeever/public-network": "workspace:*",
"croner": "^10.0.1",
"electron-updater": "^6.6.2"
}
+9
View File
@@ -39,6 +39,7 @@ import {
verifyDownloadedWindowsUpdate,
} from "./windows-update-trust.mjs";
import electronUpdater from "electron-updater";
import { createPluginPublicNetworkRuntime } from "./plugin-public-network.mjs";
const { autoUpdater } = electronUpdater;
@@ -108,6 +109,7 @@ let rendererCrashDialogOpen = false;
let rendererStartupFailureDialogOpen = false;
let rendererStartupGuard = null;
let rendererUnresponsiveTimer = null;
const pluginPublicNetwork = createPluginPublicNetworkRuntime();
let rendererUnresponsiveDialogOpen = false;
let recoveredAfterAbnormalExit = false;
const pendingScheduledTaskRuns = [];
@@ -1250,6 +1252,13 @@ const startApplication = async () => {
await saveDesktopSessionToken("");
return { stored: false };
});
ipcMain.handle("desktop:public-network-fetch", async (event, requestId, input) => {
if (event.sender !== mainWindow?.webContents) throw new Error("Public network requests must come from the main window");
return pluginPublicNetwork.fetch(requestId, input);
});
ipcMain.on("desktop:cancel-public-network-fetch", (event, requestId) => {
if (event.sender === mainWindow?.webContents && typeof requestId === "string") pluginPublicNetwork.cancel(requestId);
});
ipcMain.handle("desktop:sync-scheduled-tasks", async (event, tasks) => {
if (event.sender !== mainWindow?.webContents) throw new Error("Scheduled tasks must come from the main window");
if (!Array.isArray(tasks) || tasks.length > 1_000) throw new Error("Invalid scheduled task list");
@@ -0,0 +1,41 @@
import { nodePublicFetch } from "@edgeever/public-network/node";
import { PUBLIC_NETWORK_TIMEOUT_MS, publicRequestHeaders, publicResponseHeaders, readPublicBody, validatePublicUrl } from "@edgeever/public-network/policy";
export function createPluginPublicNetworkRuntime({ fetchPublic = nodePublicFetch, timeoutMs = PUBLIC_NETWORK_TIMEOUT_MS, maxConcurrent = 4 } = {}) {
const active = new Map();
return {
async fetch(requestId, input) {
if (typeof requestId !== "string" || requestId.length < 1 || requestId.length > 100 || !input || typeof input !== "object") throw new Error("Invalid public network request");
const url = validatePublicUrl(input.url);
const method = input.method ?? "GET";
if (!["GET", "HEAD"].includes(method)) throw new Error("Public transport supports GET/HEAD only");
const headers = publicRequestHeaders(input.headers ?? {});
if (active.size >= maxConcurrent || active.has(requestId)) throw new Error("Too many public network requests");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("Public network request timed out")), timeoutMs);
active.set(requestId, controller);
try {
const aborted = new Promise((_, reject) => {
if (controller.signal.aborted) reject(controller.signal.reason);
else controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true });
});
const response = await Promise.race([
fetchPublic(url.href, { method, headers: { ...headers, "User-Agent": "EdgeEver-Plugins/1.0" }, redirect: "manual", signal: controller.signal }),
aborted,
]);
const bytes = await readPublicBody(response, controller.signal);
return { url: url.href, status: response.status, statusText: response.statusText, headers: publicResponseHeaders(response.headers), body: bytes.buffer };
} finally {
clearTimeout(timeout);
active.delete(requestId);
}
},
cancel(requestId) {
const controller = active.get(requestId);
if (!controller) return false;
controller.abort(new Error("Public network request cancelled"));
return true;
},
get activeCount() { return active.size; },
};
}
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test";
import { createPluginPublicNetworkRuntime } from "./plugin-public-network.mjs";
const input = { url: "https://example.org/feed", method: "GET", headers: { Accept: "text/plain" } };
test("desktop public requests stay local, preserve safe metadata, and release their slot", async () => {
let received;
const runtime = createPluginPublicNetworkRuntime({ fetchPublic: async (url, init) => {
received = { url, init };
return new Response("desktop", { status: 202, headers: { "Content-Type": "text/plain", "Set-Cookie": "hidden" } });
} });
const result = await runtime.fetch("one", input);
expect(received.url).toBe(input.url); expect(received.init.headers["User-Agent"]).toBe("EdgeEver-Plugins/1.0");
expect(result.status).toBe(202); expect(new TextDecoder().decode(result.body)).toBe("desktop");
expect(result.headers).toEqual({ "content-type": "text/plain" }); expect(runtime.activeCount).toBe(0);
});
test("desktop public requests enforce validation, concurrency, timeout and cancellation", async () => {
let release; const gate = new Promise(resolve => { release = resolve; });
const runtime = createPluginPublicNetworkRuntime({ maxConcurrent: 1, timeoutMs: 10_000, fetchPublic: async () => { await gate; return new Response("ok"); } });
const pending = runtime.fetch("one", input).catch(error => error);
const busy = await runtime.fetch("two", input).catch(error => error); expect(busy.message).toContain("Too many");
expect(runtime.cancel("one")).toBe(true); const cancelled = await pending; expect(cancelled.message).toContain("cancelled"); expect(runtime.activeCount).toBe(0); release();
await expect(runtime.fetch("private", { ...input, url: "https://127.0.0.1" })).rejects.toThrow();
await expect(runtime.fetch("post", { ...input, method: "POST" })).rejects.toThrow();
});
test("desktop preload and main process expose the local transport pair", async () => {
const [main, preload] = await Promise.all([
Bun.file(new URL("./index.mjs", import.meta.url)).text(),
Bun.file(new URL("../preload/index.cjs", import.meta.url)).text(),
]);
expect(main).toContain('ipcMain.handle("desktop:public-network-fetch"');
expect(main).toContain('ipcMain.on("desktop:cancel-public-network-fetch"');
expect(preload).toContain('publicNetworkFetch: (requestId, input) => ipcRenderer.invoke("desktop:public-network-fetch"');
expect(preload).toContain('ipcRenderer.send("desktop:cancel-public-network-fetch"');
});
+2
View File
@@ -14,6 +14,8 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
copyHtml: (html, plainText) => ipcRenderer.invoke("desktop:copy-html", { html, plainText }),
setSessionToken: (value) => ipcRenderer.invoke("desktop:set-session-token", value),
clearSessionToken: () => ipcRenderer.invoke("desktop:clear-session-token"),
publicNetworkFetch: (requestId, input) => ipcRenderer.invoke("desktop:public-network-fetch", requestId, input),
cancelPublicNetworkFetch: async (requestId) => { ipcRenderer.send("desktop:cancel-public-network-fetch", requestId); },
clearLocalData: () => ipcRenderer.invoke("desktop:clear-local-data"),
recordRendererError: (details) => ipcRenderer.invoke("desktop:record-renderer-error", details),
openRendererIssue: (details) => ipcRenderer.invoke("desktop:open-renderer-issue", details),
+6 -2
View File
@@ -99,6 +99,7 @@ import { useWorkspacePreferences } from "@/hooks/useWorkspacePreferences";
import { useWorkspaceSelection } from "@/hooks/useWorkspaceSelection";
import { useWorkspaceQueuedSync } from "@/hooks/useWorkspaceQueuedSync";
import { EdgeEverPluginHost, type RegisteredPluginPanel } from "@/lib/plugins/plugin-host";
import { createPublicNetworkAdapter } from "@/lib/plugins/public-network-adapter";
import { clearRendererRecoveryRequired, isRendererRecoveryRequired } from "@/lib/renderer-recovery";
import { EditorPaneErrorBoundary, EditorRecoveryPane } from "./EditorPaneErrorBoundary";
@@ -744,11 +745,14 @@ export const WorkspaceApp = ({
? createPluginScheduleAdapter(scheduledTaskDeviceId, () =>
queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] }))
: undefined, [queryClient, scheduledTaskDeviceId]);
const pluginPublicNetworkAdapter = useMemo(() => createPublicNetworkAdapter(api.pluginNetwork, {
desktop: window.edgeeverDesktop?.isAvailable ? window.edgeeverDesktop : undefined,
}), []);
const pluginHost = useMemo(() => new EdgeEverPluginHost({
repository,
scope: localDataScope,
aiAdapter: api.pluginAi,
publicNetworkAdapter: api.pluginNetwork,
publicNetworkAdapter: pluginPublicNetworkAdapter,
onNotice: (message) => setAppNoticeDialog({ title: t("plugins.noticeTitle"), description: message }),
scheduleAdapter: pluginScheduleAdapter,
onWorkspaceChanged: async () => {
@@ -761,7 +765,7 @@ export const WorkspaceApp = ({
queryClient.invalidateQueries({ queryKey: ["resources"] }),
]);
},
}), [localDataScope, pluginScheduleAdapter, queryClient, repository, t]);
}), [localDataScope, pluginPublicNetworkAdapter, pluginScheduleAdapter, queryClient, repository, t]);
const [pluginHostReady, setPluginHostReady] = useState(false);
const pluginHostSnapshot = useSyncExternalStore(pluginHost.subscribe, pluginHost.getSnapshot, pluginHost.getSnapshot);
useEffect(() => {
@@ -59,7 +59,7 @@ describe("EdgeEverPluginHost", () => {
const calls = [];
const host = new EdgeEverPluginHost({ repository, scope: 'test',
aiAdapter: { status: async () => ({ configured: true }), generate: async input => { calls.push(input); return { text: 'HELLO' }; } },
publicNetworkAdapter: { fetchPublic: async input => { calls.push(input); return { url: input.url, status: 429, statusText: 'Too Many Requests', headers: {}, bodyBase64: btoa('limited') }; } },
publicNetworkAdapter: { fetchPublic: async input => { calls.push(input); return { url: input.url, status: 429, statusText: 'Too Many Requests', headers: {}, body: new TextEncoder().encode('limited').buffer }; } },
});
const install = async (id, permissions) => {
host.installManifest({ type: 'plugin', id, name: id, version: '1.0.0', apiVersion: '1', entry: new URL('./plugin-capabilities.fixture.mjs', import.meta.url).href, permissions: ['ui:commands', ...permissions], networkHosts: ['example.org'] }, 'https://example.org/manifest.json');
+2 -3
View File
@@ -180,7 +180,7 @@ interface PluginHostOptions {
scope: string;
aiAdapter?: PluginContext['ai'];
publicNetworkAdapter?: {
fetchPublic(input: { url: string; method: 'GET' | 'HEAD'; headers: Record<string, string> }, options?: { signal?: AbortSignal }): Promise<{ status: number; statusText: string; url: string; headers: Record<string, string>; bodyBase64: string }>;
fetchPublic(input: { url: string; method: 'GET' | 'HEAD'; headers: Record<string, string> }, options?: { signal?: AbortSignal }): Promise<{ status: number; statusText: string; url: string; headers: Record<string, string>; body: ArrayBuffer }>;
};
onWorkspaceChanged?: () => void | Promise<void>;
onNotice?: (message: string) => void;
@@ -1399,8 +1399,7 @@ export class EdgeEverPluginHost {
const data = await this.publicNetworkAdapter.fetchPublic({ url: url.href, method: method as 'GET' | 'HEAD', headers: Object.fromEntries(new Headers(requestInit.headers)) }, { signal });
signal.throwIfAborted();
if (requestInit.redirect === 'error' && data.status >= 300 && data.status < 400) throw new Error('Public request returned a redirect.');
const bytes = Uint8Array.from(atob(data.bodyBase64), c => c.charCodeAt(0));
const response = new Response(method === 'HEAD' || [204, 205, 304].includes(data.status) ? null : bytes, { status: data.status, statusText: data.statusText, headers: data.headers });
const response = new Response(method === 'HEAD' || [204, 205, 304].includes(data.status) ? null : data.body, { status: data.status, statusText: data.statusText, headers: data.headers });
Object.defineProperty(response, 'url', { value: data.url });
return response;
}
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test";
import { createPublicNetworkAdapter } from "./public-network-adapter";
const input = { url: "https://example.org/feed", method: "GET", headers: { accept: "text/plain" } };
test("Web public transport uses a readable direct response without backend relay", async () => {
let relays = 0;
const adapter = createPublicNetworkAdapter({ fetchPublic: async () => { relays++; throw new Error("unused"); } }, { direct: async () => new Response("direct", { status: 200 }) });
const result = await adapter.fetchPublic(input);
expect(new TextDecoder().decode(result.body)).toBe("direct"); expect(relays).toBe(0);
});
test("Web public transport falls back only for browser network/CORS failure", async () => {
let relays = 0;
const relay = { fetchPublic: async value => { relays++; return { ...value, status: 200, statusText: "OK", body: new TextEncoder().encode("relay").buffer }; } };
const adapter = createPublicNetworkAdapter(relay, { direct: async () => { throw new TypeError("Failed to fetch"); } });
expect(new TextDecoder().decode((await adapter.fetchPublic(input)).body)).toBe("relay"); expect(relays).toBe(1);
const limited = createPublicNetworkAdapter(relay, { direct: async () => { throw new Error("Public response exceeds the size limit."); } });
await expect(limited.fetchPublic(input)).rejects.toThrow("size limit"); expect(relays).toBe(1);
});
test("desktop public transport stays on the device and propagates cancellation", async () => {
let relays = 0, cancelled = "";
const desktop = {
publicNetworkFetch: async (_id, value) => ({ ...value, status: 200, statusText: "OK", body: new ArrayBuffer(0) }),
cancelPublicNetworkFetch: async id => { cancelled = id; },
};
const adapter = createPublicNetworkAdapter({ fetchPublic: async () => { relays++; throw new Error("unused"); } }, { desktop });
expect((await adapter.fetchPublic(input)).status).toBe(200); expect(relays).toBe(0);
const controller = new AbortController(); let rejectPending;
const pendingDesktop = { publicNetworkFetch: () => new Promise((_resolve, reject) => { rejectPending = reject; }), cancelPublicNetworkFetch: async id => { cancelled = id; rejectPending(new Error("cancelled")); } };
const pending = createPublicNetworkAdapter({ fetchPublic: async () => { throw new Error("unused"); } }, { desktop: pendingDesktop }).fetchPublic(input, { signal: controller.signal });
controller.abort(); await expect(pending).rejects.toThrow(); expect(cancelled).not.toBe("");
});
@@ -0,0 +1,65 @@
import type { PluginPublicFetchRequest, PluginPublicFetchResponse } from "@edgeever/shared";
const MAX_BYTES = 2_000_000;
const SAFE_RESPONSE_HEADERS = new Set(["content-type", "cache-control", "etag", "last-modified", "location", "retry-after", "content-range", "accept-ranges"]);
type Relay = { fetchPublic(input: PluginPublicFetchRequest, options?: { signal?: AbortSignal }): Promise<PluginPublicFetchResponse> };
type DesktopBridge = Pick<EdgeEverDesktopBridge, "publicNetworkFetch" | "cancelPublicNetworkFetch">;
async function responseToResult(input: PluginPublicFetchRequest, response: Response, signal?: AbortSignal): Promise<PluginPublicFetchResponse> {
signal?.throwIfAborted();
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > MAX_BYTES) throw new Error("Public response exceeds the size limit.");
const reader = response.body?.getReader();
const chunks: Uint8Array[] = []; let size = 0;
if (reader) {
const abort = () => { void reader.cancel().catch(() => undefined); };
signal?.addEventListener("abort", abort, { once: true });
try {
while (true) {
const { done, value } = await reader.read(); signal?.throwIfAborted();
if (done) break;
size += value.byteLength;
if (size > MAX_BYTES) throw new Error("Public response exceeds the size limit.");
chunks.push(value);
}
} finally { signal?.removeEventListener("abort", abort); await reader.cancel().catch(() => undefined); }
}
const bytes = new Uint8Array(size); let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
const headers: Record<string, string> = {};
for (const [name, value] of response.headers) if (SAFE_RESPONSE_HEADERS.has(name.toLowerCase())) headers[name.toLowerCase()] = value.slice(0, 2_000);
return { url: input.url, status: response.status, statusText: response.statusText, headers, body: bytes.buffer };
}
export function createPublicNetworkAdapter(relay: Relay, options: {
direct?: typeof fetch;
desktop?: DesktopBridge;
} = {}): Relay {
return {
async fetchPublic(input, requestOptions) {
const signal = requestOptions?.signal;
if (options.desktop) {
const requestId = crypto.randomUUID();
const abort = () => { void options.desktop!.cancelPublicNetworkFetch(requestId); };
signal?.addEventListener("abort", abort, { once: true });
try {
signal?.throwIfAborted();
const result = await options.desktop.publicNetworkFetch(requestId, input);
signal?.throwIfAborted();
return result;
} finally { signal?.removeEventListener("abort", abort); }
}
const direct = options.direct ?? globalThis.fetch;
try {
const response = await direct(input.url, { method: input.method, headers: input.headers, credentials: "omit", redirect: "manual", signal });
if (response.type === "opaqueredirect" || response.status === 0) throw new TypeError("Browser could not expose the public response");
return await responseToResult(input, response, signal);
} catch (error) {
signal?.throwIfAborted();
// Browser fetch rejects CORS and network failures with TypeError. The authenticated relay is the Web fallback.
if (!(error instanceof TypeError)) throw error;
return relay.fetchPublic(input, requestOptions);
}
},
};
}
+2
View File
@@ -25,6 +25,8 @@ interface EdgeEverDesktopBridge {
copyHtml(html: string, plainText: string): Promise<boolean>;
setSessionToken(value: string): Promise<{ stored: boolean }>;
clearSessionToken(): Promise<{ stored: false }>;
publicNetworkFetch(requestId: string, input: import("@edgeever/shared").PluginPublicFetchRequest): Promise<import("@edgeever/shared").PluginPublicFetchResponse>;
cancelPublicNetworkFetch(requestId: string): Promise<void>;
clearLocalData(): Promise<
{ scheduled: true }
| { scheduled: false; errorCode: DesktopLocalDataResetErrorCode }
+13 -1
View File
@@ -43,7 +43,6 @@
"fflate": "^0.8.3",
"hono": "latest",
"i18next": "^26.3.4",
"ipaddr.js": "^2.2.0",
"katex": "0.16.22",
"lucide-react": "latest",
"marked": "^18.0.5",
@@ -90,11 +89,15 @@
"apps/api": {
"name": "@edgeever/api",
"version": "0.1.3",
"dependencies": {
"@edgeever/public-network": "workspace:*",
},
},
"apps/desktop": {
"name": "@edgeever/desktop",
"version": "1.54.0",
"dependencies": {
"@edgeever/public-network": "workspace:*",
"croner": "^10.0.1",
"electron-updater": "^6.6.2",
},
@@ -236,6 +239,13 @@
"name": "@edgeever/plugin-api",
"version": "0.1.0",
},
"packages/public-network": {
"name": "@edgeever/public-network",
"version": "0.1.0",
"dependencies": {
"ipaddr.js": "^2.2.0",
},
},
"packages/shared": {
"name": "@edgeever/shared",
"version": "0.1.3",
@@ -643,6 +653,8 @@
"@edgeever/plugin-api": ["@edgeever/plugin-api@workspace:packages/plugin-api"],
"@edgeever/public-network": ["@edgeever/public-network@workspace:packages/public-network"],
"@edgeever/shared": ["@edgeever/shared@workspace:packages/shared"],
"@edgeever/site": ["@edgeever/site@workspace:apps/site"],
+4 -2
View File
@@ -485,7 +485,7 @@ const result = await context.ai.generate({
});
```
`system` is limited to 8,000 characters, `prompt` to 90,000, output to 5,000 tokens, and generation to 120 seconds. The backend requires an interactive user session, disables these capabilities in public demo mode, and redacts provider errors. Each workspace has up to four concurrent calls per AI/network group per app instance; this is not a distributed quota. Model charges follow the configured provider. Plugin deactivation aborts outstanding calls.
`system` is limited to 8,000 characters, `prompt` to 90,000, output to 5,000 tokens, and generation to 120 seconds. The backend requires an interactive user session, disables AI in public demo mode, and redacts provider errors. AI calls have a four-request per-workspace guard in each backend instance; this is not a distributed quota. Model charges follow the configured provider. Plugin deactivation aborts outstanding calls.
Existing `network.fetch(url, init)` remains browser fetch with CORS and omitted credentials. Add both `network` and `network:public`, declare `networkHosts`, and explicitly select `transport: "public"` to use the generic public Internet transport:
@@ -508,6 +508,8 @@ const feed = await response.text(); // Parse inside the plugin.
Public mode supports HTTPS GET/HEAD on port 443, no request body or credentials, a 20-second deadline, and at most 2,000,000 decoded response bytes. It always returns redirects without following them (`redirect: "error"` rejects them). Upstream 403/429 remain upstream status codes; this transport does not bypass platform restrictions. Allowed request headers: Accept, Accept-Language, If-None-Match, If-Modified-Since, Range. Only content/cache metadata, Location and Retry-After are returned; Set-Cookie is excluded. The response is buffered within the size limit, not an unlimited streaming proxy.
Web and desktop clients use the same authenticated backend transport. Cloudflare uses default workerd public-only Internet egress and no private-service bindings. Self-hosted Bun uses a TLS lookup callback which validates DNS answers and connects directly to those answers; private, special-use and mixed public/private answers are rejected. Synthetic VPN/fake-IP DNS answers in private or reserved ranges are also rejected; do not disable this check. Nonstandard workerd deployments must preserve public-only global egress.
The host selects the least expensive safe transport without changing the plugin API. Web first tries browser fetch; a readable CORS response stays entirely client-side, while a browser network/CORS `TypeError` falls back to the authenticated backend relay. Desktop uses its Electron main process and the user's own network, capped at four concurrent requests. It does not relay public content through the EdgeEver backend. Cancellation propagates to every transport.
All native/server drivers share one policy package. Desktop and self-hosted Bun validate every DNS answer and pass the validated address directly to TLS; private, special-use and mixed public/private answers are rejected. Cloudflare fallback uses workerd's default public-only Internet egress and no private-service bindings. Synthetic VPN/fake-IP DNS answers in reserved ranges are rejected; do not disable this check. Nonstandard workerd deployments must preserve public-only global egress. The Web fallback returns bounded binary bytes rather than Base64 JSON, avoiding Base64's transfer expansion.
Plugin permission/domain checks run in the trusted client host. Backend routes independently require user authentication and enforce public-only egress; they do not trust client-supplied plugin IDs or allowlists, and do not claim server-attested per-plugin isolation. The trusted-JavaScript limitation still applies. The backend never receives a source enum, search window, evidence schema or report workflow.
+4 -2
View File
@@ -485,7 +485,7 @@ const result = await context.ai.generate({
});
```
`system` 最多 8,000 字符,`prompt` 最多 90,000 字符,输出最多 5,000 token,生成最长 120 秒。后端要求交互式用户会话,公开演示模式禁用这些能力,供应商错误脱敏。每工作区、每应用运行实例的 AI/网络两组调用各最多四路并发,不是分布式配额。模型费用沿用已配置供应商的计费;停用插件会中止其调用。
`system` 最多 8,000 字符,`prompt` 最多 90,000 字符,输出最多 5,000 token,生成最长 120 秒。后端要求交互式用户会话,公开演示模式禁用 AI,供应商错误脱敏。每个后端实例对每工作区的 AI 调用设置四路并发保护,不是分布式配额。模型费用沿用已配置供应商的计费;停用插件会中止其调用。
已有 `network.fetch(url, init)` 保留浏览器 fetch 行为,受 CORS 限制并省略凭据。使用通用公开网络传输时,需要同时声明 `network`、`network:public` 和 `networkHosts`,并显式选择 `transport: "public"`:
@@ -508,6 +508,8 @@ const feed = await response.text(); // 插件自己解析。
公开模式仅支持 443 端口的 HTTPS GET/HEAD,不携带请求体和凭据;超时 20 秒,解码后的响应正文最多 2,000,000 字节。重定向只返回、不跟随(`redirect: "error"` 会拒绝)。来源的 403/429 保留为来源状态,不绕过平台访问限制。允许的请求头为 Accept、Accept-Language、If-None-Match、If-Modified-Since、Range;仅返回内容/缓存元数据、Location 和 Retry-After,不返回 Set-Cookie。响应在上限内缓冲,不是无限流式代理。
Web 和桌面客户端共用认证后的后端传输。Cloudflare 使用 workerd 默认的仅公开 Internet 出口,不使用私网服务绑定。Bun 自托管使用 TLS lookup 回调,校验 DNS 结果后直接连接这些地址;私网、特殊用途和混合公私地址全部拒绝。VPN/fake-IP DNS 返回的保留地址也会拒绝,不应禁用检查;非标准 workerd 部署须保留仅公开网络出口。
宿主在不改变插件 API 的前提下选择成本最低的安全传输。Web 先尝试浏览器请求:CORS 可读的响应完全留在客户端;只有浏览器以网络/CORS `TypeError` 拒绝时,才回退到已认证的后端中继。桌面端通过 Electron 主进程和用户本机网络请求,最多四路并发,不再把公开内容转发到 EdgeEver 后端。取消信号会传递到所有传输路径。
桌面端、自托管与云端驱动共用同一策略包。桌面端和 Bun 自托管会校验全部 DNS 结果,并把已校验地址直接交给 TLS;私网、特殊用途和混合公私地址全部拒绝。Cloudflare 回退使用 workerd 默认的仅公开 Internet 出口,不使用私网服务绑定。VPN/fake-IP DNS 返回的保留地址也会拒绝,不应禁用检查;非标准 workerd 部署须保留仅公开网络出口。Web 回退返回有大小限制的二进制正文,不再使用 Base64 JSON,避免 Base64 的额外传输体积。
插件权限和域名检查在可信客户端宿主执行。后端独立要求用户认证并限制仅公开网络,不信任客户端提交的插件 ID 或白名单,也不声称提供服务端证明的插件隔离;仍遵循可信 JavaScript 边界。后端不接收来源枚举、搜索时间范围、证据结构或报告流程。
+1 -2
View File
@@ -59,7 +59,7 @@
"release": "bun scripts/release.mjs",
"publish:stores": "bun scripts/store-delivery.mjs",
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs",
"verify:packaged-desktop-startup": "bun scripts/verify-packaged-desktop-startup.mjs",
"verify:desktop-package": "bun scripts/verify-desktop-package.mjs",
@@ -148,7 +148,6 @@
"fflate": "^0.8.3",
"hono": "latest",
"i18next": "^26.3.4",
"ipaddr.js": "^2.2.0",
"katex": "0.16.22",
"lucide-react": "latest",
"marked": "^18.0.5",
+27 -1
View File
@@ -49,6 +49,8 @@ import type {
SyncChange,
SyncChangesResponse,
DeploymentMetadata,
PluginPublicFetchRequest,
PluginPublicFetchResponse,
} from "@edgeever/shared";
const MAX_SINGLE_REQUEST_UPLOAD_BYTES = 5 * 1024 * 1024;
@@ -387,6 +389,30 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
return response.json() as Promise<T>;
};
const requestPluginPublic = async (input: PluginPublicFetchRequest, signal?: AbortSignal): Promise<PluginPublicFetchResponse> => {
const { context, response } = await send("/api/v1/plugins/network/fetch", {
method: "POST",
body: JSON.stringify(input),
signal,
});
const upstreamStatus = response.headers.get("x-edgeever-upstream-status");
if (!response.ok || !upstreamStatus) await throwRequestError(context, response, "Public network request failed");
const status = Number(upstreamStatus);
if (!Number.isInteger(status) || status < 100 || status > 599) throw new Error("Invalid public network response status");
const headers: Record<string, string> = {};
for (const [key, value] of response.headers) {
const prefix = "x-edgeever-upstream-header-";
if (key.startsWith(prefix)) headers[key.slice(prefix.length)] = value;
}
return {
url: input.url,
status,
statusText: decodeURIComponent(response.headers.get("x-edgeever-upstream-status-text") ?? ""),
headers,
body: await response.arrayBuffer(),
};
};
const requestResourceResponse = async (path: string, init?: RequestInit) => {
const isAbsolute = isAbsoluteHttpUrl(path);
const { context, response } = await send(path, init, {
@@ -565,7 +591,7 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
return {
getInstanceHealth: () => request<InstanceHealth>("/api/health"),
...createPluginCapabilities(request),
...createPluginCapabilities(request, requestPluginPublic),
getInstanceRelease: () => request<InstanceRelease>("/api/release"),
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";
import { createEdgeEverClient } from "./index";
test("plugin public client preserves binary bytes and upstream metadata without Base64", async () => {
const client = createEdgeEverClient({ baseUrl: "https://edgeever.test", fetch: async (_url, init) => {
expect(JSON.parse(init.body).url).toBe("https://example.org/feed");
return new Response(new Uint8Array([0, 127, 128, 255]), { headers: {
"X-EdgeEver-Upstream-Status": "206",
"X-EdgeEver-Upstream-Status-Text": "Partial%20Content",
"X-EdgeEver-Upstream-Header-Content-Type": "application/octet-stream",
} });
} });
const result = await client.pluginNetwork.fetchPublic({ url: "https://example.org/feed", method: "GET", headers: {} });
expect(result.status).toBe(206); expect(result.statusText).toBe("Partial Content");
expect(result.headers).toEqual({ "content-type": "application/octet-stream" });
expect([...new Uint8Array(result.body)]).toEqual([0, 127, 128, 255]);
});
+3 -2
View File
@@ -1,14 +1,15 @@
import type { PluginAiGenerateRequest, PluginAiStatus, PluginPublicFetchRequest, PluginPublicFetchResponse } from '@edgeever/shared';
type Request = <T>(path: string, init?: RequestInit) => Promise<T>;
export function createPluginCapabilities(request: Request) {
type PublicRequest = (input: PluginPublicFetchRequest, signal?: AbortSignal) => Promise<PluginPublicFetchResponse>;
export function createPluginCapabilities(request: Request, requestPublic: PublicRequest) {
return {
pluginAi: {
status: () => request<PluginAiStatus>('/api/v1/plugins/ai/status'),
generate: ({ signal, ...input }: PluginAiGenerateRequest & { signal?: AbortSignal }) => request<{ text: string }>('/api/v1/plugins/ai/generate', { method: 'POST', body: JSON.stringify(input), signal }),
},
pluginNetwork: {
fetchPublic: (input: PluginPublicFetchRequest, options?: { signal?: AbortSignal }) => request<PluginPublicFetchResponse>('/api/v1/plugins/network/fetch', { method: 'POST', body: JSON.stringify(input), signal: options?.signal }),
fetchPublic: (input: PluginPublicFetchRequest, options?: { signal?: AbortSignal }) => requestPublic(input, options?.signal),
},
};
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@edgeever/public-network",
"version": "0.1.3",
"private": true,
"type": "module",
"files": ["src/"],
"exports": {
"./policy": { "types": "./src/policy.d.ts", "default": "./src/policy.mjs" },
"./node": { "types": "./src/node.d.ts", "default": "./src/node.mjs" }
},
"dependencies": { "ipaddr.js": "^2.2.0" }
}
+6
View File
@@ -0,0 +1,6 @@
import type { LookupAddress } from "node:dns";
import type { LookupFunction } from "node:net";
type ResolveAll = (hostname: string, options: { all: true; verbatim: true }, callback: (error: NodeJS.ErrnoException | null, addresses: LookupAddress[]) => void) => void;
export function createPublicLookup(resolve?: ResolveAll): LookupFunction;
export const publicLookup: LookupFunction;
export function nodePublicFetch(input: string, init?: RequestInit): Promise<Response>;
+43
View File
@@ -0,0 +1,43 @@
import { lookup } from "node:dns";
import { request } from "node:https";
import { createBrotliDecompress, createUnzip } from "node:zlib";
import { isPublicAddress, validatePublicUrl } from "./policy.mjs";
export function createPublicLookup(resolve = lookup) { return (hostname, options, callback) => {
resolve(hostname, { all: true, verbatim: true }, (error, addresses) => {
if (error) { callback(error, "", 0); return; }
if (!addresses.length || addresses.some(entry => !isPublicAddress(entry.address))) { callback(new Error("The destination does not resolve exclusively to public addresses."), "", 0); return; }
const candidates = options.family ? addresses.filter(entry => entry.family === options.family) : addresses;
if (!candidates.length) { callback(new Error("No public address in the requested family."), "", 0); return; }
if (options.all) callback(null, candidates);
else callback(null, candidates[0].address, candidates[0].family);
});
}; }
export const publicLookup = createPublicLookup();
export async function nodePublicFetch(input, init = {}) {
const url = validatePublicUrl(input);
init.signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const headers = Object.fromEntries(new Headers(init.headers));
const req = request(url, { method: init.method ?? "GET", headers: { ...headers, "accept-encoding": "identity" }, lookup: publicLookup, agent: false, signal: init.signal ?? undefined }, incoming => {
const encoding = incoming.headers["content-encoding"];
const stream = encoding === "gzip" || encoding === "deflate" ? incoming.pipe(createUnzip()) : encoding === "br" ? incoming.pipe(createBrotliDecompress()) : incoming;
if (encoding && !["identity", "gzip", "deflate", "br"].includes(encoding)) { incoming.destroy(); reject(new Error("Unsupported response encoding.")); return; }
const responseHeaders = new Headers();
for (const [key, value] of Object.entries(incoming.headers)) if (value !== undefined && !["content-encoding", "content-length", "transfer-encoding"].includes(key)) responseHeaders.set(key, Array.isArray(value) ? value.join(", ") : value);
const iterator = stream[Symbol.asyncIterator]();
const abort = () => { incoming.destroy(); stream.destroy(); };
init.signal?.addEventListener("abort", abort, { once: true });
const cleanUp = () => { init.signal?.removeEventListener("abort", abort); };
const status = incoming.statusCode ?? 502;
if (init.method === "HEAD" || [204, 205, 304].includes(status)) { abort(); cleanUp(); resolve(new Response(null, { status, headers: responseHeaders })); return; }
const body = new ReadableStream({
async pull(controller) { try { init.signal?.throwIfAborted(); const chunk = await iterator.next(); init.signal?.throwIfAborted(); if (chunk.done) { cleanUp(); controller.close(); } else controller.enqueue(new Uint8Array(chunk.value)); } catch (error) { cleanUp(); controller.error(error); abort(); } },
cancel() { cleanUp(); abort(); },
});
resolve(new Response(body, { status, statusText: incoming.statusMessage, headers: responseHeaders }));
});
req.on("error", reject); req.end();
});
}
+7
View File
@@ -0,0 +1,7 @@
export const PUBLIC_NETWORK_MAX_BYTES: number;
export const PUBLIC_NETWORK_TIMEOUT_MS: number;
export function isPublicAddress(address: string): boolean;
export function validatePublicUrl(input: string): URL;
export function publicRequestHeaders(input: Record<string, string>): Record<string, string>;
export function publicResponseHeaders(headers: Headers): Record<string, string>;
export function readPublicBody(response: Response, signal: AbortSignal): Promise<Uint8Array>;
+56
View File
@@ -0,0 +1,56 @@
import ipaddr from "ipaddr.js";
export const PUBLIC_NETWORK_MAX_BYTES = 2_000_000;
export const PUBLIC_NETWORK_TIMEOUT_MS = 20_000;
const REQUEST_HEADERS = new Set(["accept", "accept-language", "if-none-match", "if-modified-since", "range"]);
const RESPONSE_HEADERS = new Set(["content-type", "cache-control", "etag", "last-modified", "location", "retry-after", "content-range", "accept-ranges"]);
export function isPublicAddress(address) {
try { return ipaddr.process(address).range() === "unicast"; } catch { return false; }
}
export function validatePublicUrl(input) {
const url = new URL(input);
const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (url.protocol !== "https:" || (url.port && url.port !== "443") || url.username || url.password || url.hash) throw new Error("Only public HTTPS URLs on port 443 without credentials or fragments are supported.");
if (ipaddr.isValid(hostname) || !hostname.includes(".") || hostname.endsWith(".") || /(?:^|\.)(localhost|local|internal|test|invalid|example|onion|home|lan|arpa)$/.test(hostname)) throw new Error("A public Internet hostname is required.");
return url;
}
export function publicRequestHeaders(input) {
const result = {};
if (Object.keys(input).length > 10) throw new Error("Too many request headers.");
for (const [key, value] of Object.entries(input)) {
const name = key.toLowerCase();
if (!REQUEST_HEADERS.has(name) || typeof value !== "string" || /[\r\n\0]/.test(value)) throw new Error("Unsupported public request header.");
result[name] = value;
}
return result;
}
export function publicResponseHeaders(headers) {
const result = {};
for (const [key, value] of headers) if (RESPONSE_HEADERS.has(key.toLowerCase())) result[key.toLowerCase()] = value.slice(0, 2_000);
return result;
}
export async function readPublicBody(response, signal) {
const reader = response.body?.getReader();
if (!reader) return new Uint8Array();
const chunks = []; let size = 0;
const cancel = () => { void reader.cancel().catch(() => undefined); };
signal.addEventListener("abort", cancel, { once: true });
try {
signal.throwIfAborted();
while (true) {
const { done, value } = await reader.read(); signal.throwIfAborted();
if (done) break;
size += value.byteLength;
if (size > PUBLIC_NETWORK_MAX_BYTES) throw new Error("Public response exceeds the size limit.");
chunks.push(value);
}
const bytes = new Uint8Array(size); let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
return bytes;
} finally { signal.removeEventListener("abort", cancel); await reader.cancel().catch(() => undefined); }
}
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";
import { isPublicAddress, publicRequestHeaders, publicResponseHeaders, readPublicBody, validatePublicUrl } from "../src/policy.mjs";
test("shared public policy blocks local targets and sensitive headers", () => {
for (const address of ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "::ffff:127.0.0.1", "2001:db8::1"]) expect(isPublicAddress(address)).toBe(false);
expect(isPublicAddress("8.8.8.8")).toBe(true);
for (const url of ["http://example.org", "https://localhost", "https://127.0.0.1", "https://metadata.google.internal"]) expect(() => validatePublicUrl(url)).toThrow();
expect(() => publicRequestHeaders({ Authorization: "secret" })).toThrow();
});
test("shared public policy bounds bodies and response metadata", async () => {
const headers = publicResponseHeaders(new Headers({ "Content-Type": "text/plain", "Set-Cookie": "secret", "X-Other": "hidden" }));
expect(headers).toEqual({ "content-type": "text/plain" });
const signal = new AbortController().signal;
expect(new TextDecoder().decode(await readPublicBody(new Response("ok"), signal))).toBe("ok");
await expect(readPublicBody(new Response(new Uint8Array(2_000_001)), signal)).rejects.toThrow("size limit");
});
+1 -1
View File
@@ -19,5 +19,5 @@ export interface PluginPublicFetchResponse {
status: number;
statusText: string;
headers: Record<string, string>;
bodyBase64: string;
body: ArrayBuffer;
}