Merge pull request #25 from Nystik-gh/v0.8.5

0.8.5: Server settings, SSRF guard, shim coverage
This commit is contained in:
Nystik
2026-06-07 13:56:02 +02:00
committed by GitHub
57 changed files with 1823 additions and 167 deletions

View File

@@ -2,6 +2,27 @@
All notable changes to this project will be documented in this file.
## [0.8.5] - Karm (2026-06-07)
### Added
- Server settings panel in the Ignis settings tab.
- `assert`, `constants`, and `stream` shims, plus callback-style `fs` methods and `realpath`.
### Changed
- Write coalescing is now off by default (`WRITE_COALESCE_MS=0`).
### Fixed
- Native menus now stay disabled on platforms where its default is true
- `/app/data` is now created and owned by the runtime user.
- Caddy reverse-proxy example uses the current `basic_auth` directive.
### Security
- Cross-origin proxy rejects requests that resolve to private, loopback, or link-local addresses (SSRF guard).
## [0.8.4] - Karm (2026-06-03)
### Fixed

View File

@@ -74,12 +74,12 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi
**Server-side integration.**
- Adds a plugin system inside the server itself, separate from Obsidian's community plugin system (WIP).
- Ignis-specific settings appear as their own tabs inside Obsidian's Settings modal.
- Server runtime settings (cache sizes, request body limit, etc.) are configurable from the Ignis settings panel.
- Status bar indicators surface server state and headless sync activity.
## Roadmap
**Planned:**
- Server parameter configuration from the Ignis settings panel (LRU cache size, write coalesce window, etc.)
- Continued shim work to support more community plugins.
- Server-side plugin system improvements.
@@ -94,7 +94,7 @@ A few design decisions worth knowing about for someone evaluating Ignis against
- A pre-compressed bootstrap response delivers vault info, vault list, metadata tree, and plugin list in a single call.
- Indexer pre-fetch warms the content cache so Obsidian's startup index hits cache instead of the network.
- An LRU content cache (50 MB by default) keeps memory use bounded regardless of vault size, so Ignis doesn't hold the whole vault in memory.
- Write coalescing debounces rapid writes for slow filesystems (rclone, FUSE, NFS, SMB).
- Optional write coalescing debounces rapid writes for slow filesystems (rclone, FUSE, NFS, SMB); off unless `WRITE_COALESCE_MS` is set.
## Browser compatibility

View File

@@ -28,6 +28,8 @@ Example configurations for Basic Auth and Authelia are in [`examples/`](examples
> [!CAUTION]
> Do not run Ignis on a public network without auth. Anyone with the URL can read and write your vault files.
Ignis also runs a cross-origin proxy (`/api/proxy`) that reaches any public host by default. It rejects private, loopback, and link-local addresses, and you can narrow it to an allowlist or disable it entirely from the proxy settings in the Ignis settings panel.
## Setup with Docker Compose
Example `docker-compose.yml`:
@@ -76,7 +78,7 @@ To build from source instead of pulling the image, clone the repo and run `docke
| `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` |
| `PUID` | User ID for file ownership | `1000` |
| `PGID` | Group ID for file ownership | `1000` |
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. Useful for slow filesystems (rclone, NFS, SMB). Set to `0` to disable. | `5000` |
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. On slow filesystems (rclone, NFS, SMB), set an appropriate duration. | `0` |
| `WS_ORIGINS` | Comma-separated allowlist of `Origin` headers accepted on the WebSocket endpoint. When unset, any origin is accepted. | unset |
Demo mode adds its own set of env vars (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [`examples/demo/`](examples/demo/) if you want to run a public demo deployment.

View File

@@ -1,6 +1,6 @@
# Replace with your domain, or use :443 for local access with a self-signed cert.
ignis.example.com {
basicauth {
basic_auth {
# Username: admin
# Replace the hash below with your own. Generate one with:
# docker run --rm caddy:2 caddy hash-password --plaintext YOUR_PASSWORD

View File

@@ -23,8 +23,9 @@ else
echo "[ignis] Using existing user $RUN_USER (UID $PUID)"
fi
# Fix ownership of volumes
chown -R "$PUID:$PGID" /vaults /app/obsidian-app
mkdir -p /app/data
chown -R "$PUID:$PGID" /vaults /app/obsidian-app /app/data
OBSIDIAN_DIR="/app/obsidian-app"
OBSIDIAN_VERSION="${OBSIDIAN_VERSION:-1.12.7}"

View File

@@ -75,16 +75,6 @@ module.exports = {
vaults = discoverVaults();
return vaults;
},
writeCoalesceMs:
process.env.WRITE_COALESCE_MS !== undefined
? parseInt(process.env.WRITE_COALESCE_MS)
: 5000,
wsOrigins: process.env.WS_ORIGINS
? process.env.WS_ORIGINS.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null,
demoMode: process.env.DEMO_MODE === "true",
demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20,

View File

@@ -70,6 +70,11 @@ function setupDemo(app) {
// Hide server-side plugins (headless-sync) from the demo UI
app.use("/api/plugins", pluginsBlocker);
// Server settings are-fixed in demo mode.
app.use("/api/settings", (req, res) => {
res.status(403).json({ error: "Settings are disabled in demo mode" });
});
// Cleanup timer
const interval = setInterval(() => {
cleanupExpired().catch((e) =>

View File

@@ -3,6 +3,7 @@ const fs = require("fs");
const path = require("path");
const compression = require("compression");
const config = require("./config");
const settings = require("./settings");
const { getVersion } = require("./version");
const {
setupWebSocket,
@@ -19,7 +20,7 @@ const {
getBundledPluginDirs,
} = require("./plugin-system/manager");
const pluginRoutes = require("./routes/plugins");
writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs });
writeCoalescer.configure({ writeCoalesceMs: settings.get("writeCoalesceMs") });
const { flushAll } = writeCoalescer;
const { setupDemo, wireDemoWebSocket } = require("./demo");
@@ -32,7 +33,18 @@ const ANSI_RESET = "\x1b[0m";
const app = express();
app.use(express.json({ limit: "50mb" }));
// Reject oversized requests by Content-Length before parsing.
app.use((req, res, next) => {
const declared = Number(req.headers["content-length"]);
if (Number.isFinite(declared) && declared > settings.get("maxBodyBytes")) {
return res.status(413).json({ error: "Request body too large" });
}
next();
});
app.use(express.json({ limit: settings.MAX_BODY_BACKSTOP }));
app.use(compression());
// logger middleware
@@ -66,6 +78,7 @@ const fsRoutes = require("./routes/fs");
const vaultRoutes = require("./routes/vault");
const proxyRoutes = require("./routes/proxy");
const versionRoutes = require("./routes/version");
const settingsRoutes = require("./routes/settings");
const bootstrapRoutes = require("./routes/bootstrap");
app.use("/assets", express.static(path.join(__dirname, "assets")));
@@ -78,6 +91,7 @@ app.use("/api/fs", fsRoutes);
app.use("/api/vault", vaultRoutes);
app.use("/api/proxy", proxyRoutes);
app.use("/api/version", versionRoutes);
app.use("/api/settings", settingsRoutes);
app.use("/api/plugins", pluginRoutes);
app.use("/api/bootstrap", bootstrapRoutes);
@@ -197,7 +211,7 @@ const server = app.listen(config.port, async () => {
const wss = setupWebSocket(server, {
getVaultPath: config.getVaultPath,
originAllowlist: config.wsOrigins,
originAllowlist: settings.get("wsOrigins"),
});
wireDemoWebSocket(server);

View File

@@ -14,6 +14,7 @@ const {
getVirtualPluginsForVault,
} = require("../plugin-system/manager");
const { getVersion } = require("../version");
const settings = require("../settings");
const router = express.Router();
@@ -140,6 +141,11 @@ async function buildEntry(vaultId) {
// In demo mode, hide server-side plugins from the client.
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
virtualPlugins: getVirtualPluginsForVault(vaultId, getVersion()),
settings: {
contentCacheBytes: settings.get("contentCacheBytes"),
inputCacheBytes: settings.get("inputCacheBytes"),
inputCacheTtlMs: settings.get("inputCacheTtlMs"),
},
};
const jsonBuf = Buffer.from(JSON.stringify(response));
@@ -185,6 +191,10 @@ function invalidateVault(vaultId) {
cache.delete(vaultId);
}
function invalidateAll() {
cache.clear();
}
async function warmUp() {
const ids = Object.keys(config.vaults);
@@ -251,4 +261,5 @@ router.get("/", async (req, res) => {
module.exports = router;
module.exports.invalidateVault = invalidateVault;
module.exports.invalidateAll = invalidateAll;
module.exports.warmUp = warmUp;

View File

@@ -1,9 +1,98 @@
const express = require("express");
const dns = require("dns").promises;
const net = require("net");
const settings = require("../settings");
const router = express.Router();
// POST /api/proxy - forward a request to an external URL to bypass CORS
// Used by the requestUrl shim for plugin installation, etc.
const MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
function isPrivateIp(ip) {
const type = net.isIP(ip);
if (type === 4) {
const o = ip.split(".").map(Number);
return (
o[0] === 0 ||
o[0] === 10 ||
o[0] === 127 ||
(o[0] === 169 && o[1] === 254) ||
(o[0] === 172 && o[1] >= 16 && o[1] <= 31) ||
(o[0] === 192 && o[1] === 168) ||
(o[0] === 100 && o[1] >= 64 && o[1] <= 127)
);
}
if (type === 6) {
const a = ip.toLowerCase();
if (a === "::1" || a === "::") {
return true;
}
if (/^fe[89ab]/.test(a) || a.startsWith("fc") || a.startsWith("fd")) {
return true;
}
const mapped = a.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (mapped) {
return isPrivateIp(mapped[1]);
}
return false;
}
return false;
}
function httpError(status, message) {
const e = new Error(message);
e.statusCode = status;
return e;
}
// Reject non-http(s) schemes and hosts that resolve to a private or link-local address.
async function assertPublicUrl(urlStr) {
let parsed;
try {
parsed = new URL(urlStr);
} catch {
throw httpError(400, "Invalid URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw httpError(400, "Only http and https URLs are allowed");
}
const host = parsed.hostname;
if (net.isIP(host)) {
if (isPrivateIp(host)) {
throw httpError(403, "Host not allowed");
}
return;
}
let addrs;
try {
addrs = await dns.lookup(host, { all: true });
} catch {
throw httpError(502, "DNS resolution failed");
}
for (const a of addrs) {
if (isPrivateIp(a.address)) {
throw httpError(403, "Host resolves to a private address");
}
}
}
// POST /api/proxy - forward a request to an external URL to bypass CORS.
router.post("/", async (req, res) => {
const { url, method, headers, body, binary } = req.body;
@@ -11,7 +100,31 @@ router.post("/", async (req, res) => {
return res.status(400).json({ error: "Missing url" });
}
const proxyMode = settings.get("proxyMode");
if (proxyMode === "disabled") {
return res.status(403).json({ error: "Proxy is disabled" });
}
try {
await assertPublicUrl(url);
} catch (e) {
return res.status(e.statusCode || 400).json({ error: e.message });
}
if (proxyMode === "allowlist") {
const allowlist = settings.get("proxyAllowlist");
const host = new URL(url).hostname;
if (!allowlist.includes(host)) {
return res
.status(403)
.json({ error: `Host not in proxy allowlist: ${host}` });
}
}
try {
// Forward the caller's headers as-is.
const fetchOpts = {
method: method || "GET",
headers: headers || {},
@@ -26,10 +139,25 @@ router.post("/", async (req, res) => {
}
const upstream = await fetch(url, fetchOpts);
const respBody = Buffer.from(await upstream.arrayBuffer());
// Forward response headers, stripping hop-by-hop / encoding headers
// since the body is already decompressed by Node's fetch
const declaredLength = Number(upstream.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > MAX_RESPONSE_BYTES
) {
return res.status(413).json({ error: "Upstream response too large" });
}
const respArrayBuf = await upstream.arrayBuffer();
if (respArrayBuf.byteLength > MAX_RESPONSE_BYTES) {
return res.status(413).json({ error: "Upstream response too large" });
}
const respBody = Buffer.from(respArrayBuf);
// Strip hop-by-hop / encoding headers since the body is already decompressed.
const skipHeaders = new Set([
"content-encoding",
"transfer-encoding",
@@ -37,6 +165,7 @@ router.post("/", async (req, res) => {
"connection",
]);
const respHeaders = {};
upstream.headers.forEach((val, key) => {
if (!skipHeaders.has(key)) {
respHeaders[key] = val;
@@ -54,3 +183,4 @@ router.post("/", async (req, res) => {
});
module.exports = router;
module.exports.isPrivateIp = isPrivateIp;

View File

@@ -0,0 +1,62 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { isPrivateIp } = require("./proxy.js");
describe("isPrivateIp", () => {
it("flags private and link-local IPv4", () => {
for (const ip of [
"0.0.0.0",
"10.0.0.1",
"127.0.0.1",
"169.254.1.1",
"172.16.0.1",
"172.31.255.255",
"192.168.1.1",
"100.64.0.1",
"100.127.255.255",
]) {
expect(isPrivateIp(ip), ip).toBe(true);
}
});
it("allows public IPv4, including range boundaries", () => {
for (const ip of [
"8.8.8.8",
"1.1.1.1",
"172.15.255.255",
"172.32.0.0",
"100.63.255.255",
"100.128.0.0",
"169.253.0.0",
"169.255.0.0",
"11.0.0.1",
"192.169.0.1",
]) {
expect(isPrivateIp(ip), ip).toBe(false);
}
});
it("flags private and link-local IPv6", () => {
for (const ip of ["::1", "::", "fc00::1", "fd12::1", "fe80::1", "feaf::1"]) {
expect(isPrivateIp(ip), ip).toBe(true);
}
});
it("allows public IPv6", () => {
for (const ip of ["2606:4700:4700::1111", "2001:4860:4860::8888"]) {
expect(isPrivateIp(ip), ip).toBe(false);
}
});
it("classifies IPv4-mapped IPv6 by the embedded address", () => {
expect(isPrivateIp("::ffff:127.0.0.1")).toBe(true);
expect(isPrivateIp("::ffff:8.8.8.8")).toBe(false);
});
it("returns false for non-IP input", () => {
expect(isPrivateIp("not-an-ip")).toBe(false);
expect(isPrivateIp("")).toBe(false);
});
});

View File

@@ -0,0 +1,97 @@
const express = require("express");
const { writeCoalescer } = require("@ignis/server-core");
const settings = require("../settings");
const bootstrapRoutes = require("./bootstrap");
const router = express.Router();
const NUMBER_KEYS = [
"contentCacheBytes",
"inputCacheBytes",
"inputCacheTtlMs",
"writeCoalesceMs",
"maxBodyBytes",
];
const LIST_KEYS = ["proxyAllowlist"];
function validate(body) {
const clean = {};
if (body.proxyMode !== undefined) {
if (!settings.PROXY_MODES.includes(body.proxyMode)) {
throw new Error(
`proxyMode must be one of: ${settings.PROXY_MODES.join(", ")}`,
);
}
clean.proxyMode = body.proxyMode;
}
for (const key of NUMBER_KEYS) {
if (body[key] === undefined) {
continue;
}
const n = body[key];
if (!Number.isInteger(n) || n < 0) {
throw new Error(`${key} must be a non-negative integer`);
}
if (key === "maxBodyBytes" && (n < 1 || n > settings.MAX_BODY_BACKSTOP)) {
throw new Error(
`maxBodyBytes must be between 1 and ${settings.MAX_BODY_BACKSTOP}`,
);
}
clean[key] = n;
}
for (const key of LIST_KEYS) {
if (body[key] === undefined) {
continue;
}
const list = body[key];
if (
!Array.isArray(list) ||
list.some((v) => typeof v !== "string" || !v.trim())
) {
throw new Error(`${key} must be an array of non-empty strings`);
}
clean[key] = list.map((v) => v.trim());
}
return clean;
}
function applySettings(effective) {
writeCoalescer.configure({ writeCoalesceMs: effective.writeCoalesceMs });
}
router.get("/", (req, res) => {
res.json(settings.getAll());
});
router.post("/", (req, res) => {
let clean;
try {
clean = validate(req.body || {});
} catch (e) {
return res.status(400).json({ error: e.message });
}
const effective = settings.update(clean);
applySettings(effective);
// Cache sizes ride in the bootstrap response; clear it so the next page load picks up new values.
bootstrapRoutes.invalidateAll();
res.json(effective);
});
module.exports = router;
module.exports.validate = validate;

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { validate } = require("./settings.js");
const settings = require("../settings.js");
describe("settings validate", () => {
it("rejects an unknown proxy mode", () => {
expect(() => validate({ proxyMode: "bogus" })).toThrow();
});
it("rejects negative or non-integer numbers", () => {
expect(() => validate({ contentCacheBytes: -1 })).toThrow();
expect(() => validate({ contentCacheBytes: 1.5 })).toThrow();
expect(() => validate({ contentCacheBytes: "5" })).toThrow();
});
it("enforces maxBodyBytes bounds", () => {
expect(() => validate({ maxBodyBytes: 0 })).toThrow();
expect(() =>
validate({ maxBodyBytes: settings.MAX_BODY_BACKSTOP + 1 }),
).toThrow();
expect(validate({ maxBodyBytes: 1048576 })).toEqual({
maxBodyBytes: 1048576,
});
});
it("trims a valid proxy allowlist", () => {
expect(
validate({ proxyAllowlist: [" api.example.com ", "github.com"] }),
).toEqual({ proxyAllowlist: ["api.example.com", "github.com"] });
});
it("rejects a non-array allowlist or an empty entry", () => {
expect(() => validate({ proxyAllowlist: "x" })).toThrow();
expect(() => validate({ proxyAllowlist: ["ok", " "] })).toThrow();
});
it("ignores wsOrigins, which is env-only", () => {
expect(validate({ wsOrigins: ["https://evil.example.com"] })).toEqual({});
});
it("ignores unknown keys", () => {
expect(validate({ bogusKey: 1 })).toEqual({});
});
});

View File

@@ -0,0 +1,114 @@
const fs = require("fs");
const path = require("path");
const config = require("./config");
// Runtime server settings set through UI.
const SETTINGS_FILE = path.join(config.dataRoot, "server-settings.json");
const DEFAULTS = {
contentCacheBytes: 50 * 1024 * 1024,
inputCacheBytes: 200 * 1024 * 1024,
inputCacheTtlMs: 5 * 60 * 1000,
writeCoalesceMs: 0,
maxBodyBytes: 50 * 1024 * 1024,
// "any" reaches any public host, "allowlist" restricts to proxyAllowlist, "disabled" blocks all proxying.
proxyMode: "any",
// Empty allows any public host.
proxyAllowlist: [],
wsOrigins: [],
};
const PROXY_MODES = ["any", "allowlist", "disabled"];
const KEYS = Object.keys(DEFAULTS);
// Env vars only; never persisted to the settings file.
const ENV_ONLY_KEYS = ["wsOrigins"];
// Hard ceiling for request bodies.
const MAX_BODY_BACKSTOP = 500 * 1024 * 1024;
function parseList(raw) {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
function fromEnv() {
const env = {};
if (process.env.WRITE_COALESCE_MS !== undefined) {
const n = parseInt(process.env.WRITE_COALESCE_MS, 10);
if (Number.isFinite(n)) {
env.writeCoalesceMs = n;
}
}
if (process.env.WS_ORIGINS) {
env.wsOrigins = parseList(process.env.WS_ORIGINS);
}
return env;
}
const envOverrides = fromEnv();
function loadFile() {
try {
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
// Keep only known keys so a stale or hand-edited file can't inject junk.
const clean = {};
for (const key of KEYS) {
if (ENV_ONLY_KEYS.includes(key)) {
continue;
}
if (parsed[key] !== undefined) {
clean[key] = parsed[key];
}
}
return clean;
} catch {
return {};
}
}
let fileOverrides = loadFile();
function getAll() {
return { ...DEFAULTS, ...envOverrides, ...fileOverrides };
}
function get(key) {
return getAll()[key];
}
// Merge validated changes into the persisted file and return the new effective settings.
function update(partial) {
for (const [key, value] of Object.entries(partial)) {
if (KEYS.includes(key) && !ENV_ONLY_KEYS.includes(key)) {
fileOverrides[key] = value;
}
}
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(fileOverrides, null, 2));
return getAll();
}
module.exports = {
DEFAULTS,
KEYS,
ENV_ONLY_KEYS,
PROXY_MODES,
MAX_BODY_BACKSTOP,
getAll,
get,
update,
};

View File

@@ -69,6 +69,9 @@ Immediately after the bootstrap response is applied, the client kicks off a batc
| `net` | All classes/functions throw. |
| `http` / `https` | Module is importable but `request()`/`get()` emit an `error` event; `createServer` throws. Plugins should use `requestUrl` or `fetch` (the shim routes cross-origin `fetch` through the server proxy). |
| `buffer` | Aliased to the browser `Buffer` polyfill set up by the loader. |
| `assert` | Standard assertions: `assert`, `equal`, `strictEqual`, `deepEqual`, `throws`. |
| `constants` | File access and mode constants (`F_OK`, `O_RDONLY`, `S_IFMT`, etc.) for the reported Linux platform. |
| `stream` | Base classes (`Stream`, `Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`) extending EventEmitter. Data-flow methods warn and do nothing. |
Unknown modules return an empty proxy and log a warning. The `node:` prefix is stripped. The shim exposes two console helpers, `window.__shimLog()` (everything that has been accessed) and `window.__shimMisses()` (accessed-but-missing properties).
@@ -78,7 +81,7 @@ Two caches on the client side. The **MetadataCache** holds `{ type, size, mtime,
Reads not satisfied by ContentCache go through the transport layer to `/api/fs/readFile`. Sync calls use synchronous XHR to keep Obsidian's pre-boot module code working. Async calls use fetch. The transport handles vault id injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values (`ENOENT`, `EEXIST`, `ENOTDIR`).
Writes go through a server-side write coalescer (`packages/server-core/src/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (default 5 seconds, `WRITE_COALESCE_MS`) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown.
Writes go through a server-side write coalescer (`packages/server-core/src/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (`WRITE_COALESCE_MS`, default `0` which disables coalescing) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown.
### Transforms
@@ -102,7 +105,7 @@ Obsidian on the desktop can make arbitrary cross-origin HTTP requests because it
The shim handles this transparently. `window.fetch` and `window.requestUrl` are intercepted. Same-origin requests pass through unchanged. Cross-origin requests are POSTed to `/api/proxy`, which performs the outbound call from the server with headers that mimic Obsidian's desktop runtime: `Origin: app://obsidian.md` and the browser's own User-Agent. The response body is returned base64-encoded so binary content survives the JSON round-trip; the shim decodes it and hands the caller a normal `Response` or `requestUrl` result.
The proxy itself is intentionally generic. It forwards method, headers, and body verbatim and returns whatever the upstream sent. In demo mode, an allowlist restricts the hostname to a known-safe set; in normal self-hosted mode there's no restriction, which is one of the reasons the server needs to be behind authentication when exposed to the internet.
The proxy itself is intentionally generic. It forwards method, headers, and body verbatim and returns whatever the upstream sent. It always rejects requests whose hostname resolves to a private, loopback, or link-local address (SSRF guard). Outbound access is governed by `proxyMode`: `any` (the default) reaches any public host, `allowlist` restricts to a configured host list, and `disabled` blocks all proxying; demo mode pins it to `allowlist`. Under the default `any`, the proxy is an open relay to public hosts, which is one of the reasons the server needs to be behind authentication when exposed to the internet.
### Workspaces in browser tabs
@@ -138,6 +141,7 @@ An Express server that handles filesystem operations, vault management, static f
- `/api/bootstrap` - one-shot cold-start endpoint; returns vault info + list + metadata tree + plugin list as a single pre-compressed response, cached per vault with mtime-based invalidation.
- `/api/proxy` - cross-origin HTTP proxy used by the fetch and requestUrl shims.
- `/api/version` - Ignis version (SemVer), per-build identifier, and pinned Obsidian version.
- `/api/settings/*` - read and update runtime server settings (cache sizes, request body limit, write-coalesce window, proxy mode and allowlist).
- `/api/plugins/*` - Ignis plugin management (list, enable, disable). __WIP__
- `/api/ext/:pluginId/*` - routes registered by individual Ignis plugins.
- `/vault-files/<vaultId>/<path>` - static file serving rooted at a vault, used by Obsidian for image/attachment resource URLs.

View File

@@ -1,6 +1,6 @@
{
"name": "ignis-monorepo",
"version": "0.8.4",
"version": "0.8.5",
"private": true,
"description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.",
"workspaces": [

View File

@@ -2,5 +2,6 @@
"name": "@ignis/bridge",
"version": "0.0.0-internal",
"private": true,
"type": "module",
"main": "src/main.js"
}

View File

@@ -51,4 +51,4 @@ function stopDemoGuards() {
}
}
module.exports = { startDemoGuards, stopDemoGuards };
export { startDemoGuards, stopDemoGuards, isDemoMode };

View File

@@ -1,4 +1,4 @@
const { Notice, TFile, TFolder } = require("obsidian");
import { Notice, TFile, TFolder } from "obsidian";
function getVaultId() {
return window.__currentVaultId || "";
@@ -92,4 +92,4 @@ function addFolderMenuItems(menu, folder, app) {
});
}
module.exports = { showFilePicker, addFileMenuItems, addFolderMenuItems };
export { showFilePicker, addFileMenuItems, addFolderMenuItems };

View File

@@ -1,17 +1,17 @@
const { Plugin, TFile, TFolder } = require("obsidian");
const {
import { Plugin, TFile, TFolder } from "obsidian";
import {
showFilePicker,
addFileMenuItems,
addFolderMenuItems,
} = require("./file-actions");
const {
} from "./file-actions.js";
import {
patchSettingsModal,
unpatchSettingsModal,
} = require("./settings/inject");
const pluginRegistry = require("./plugin-registry");
const { initStatusBar } = require("./status-bar");
const { WorkspacePickerModal } = require("./workspace-picker");
const { startDemoGuards, stopDemoGuards } = require("./demo-guards");
} from "./settings/inject.js";
import * as pluginRegistry from "./plugin-registry.js";
import { initStatusBar } from "./status-bar.js";
import { WorkspacePickerModal } from "./workspace-picker.js";
import { startDemoGuards, stopDemoGuards } from "./demo-guards.js";
class IgnisBridgePlugin extends Plugin {
async onload() {
@@ -65,4 +65,4 @@ class IgnisBridgePlugin extends Plugin {
}
}
module.exports = IgnisBridgePlugin;
export default IgnisBridgePlugin;

View File

@@ -34,4 +34,4 @@ function getKnownIds() {
return knownIds;
}
module.exports = { refresh, isIgnisPlugin, addId, getKnownIds };
export { refresh, isIgnisPlugin, addId, getKnownIds };

View File

@@ -1,4 +1,7 @@
const { Setting } = require("obsidian");
import { Setting, Notice } from "obsidian";
import { isDemoMode } from "../demo-guards.js";
import { stripBuildMetadata, isNewer } from "../util/version.js";
import { ListEditorModal } from "./list-editor-modal.js";
const GITHUB_URL = "https://github.com/Nystik-gh/ignis";
const GITHUB_API_LATEST =
@@ -8,11 +11,6 @@ function getVersion() {
return window.__ignis?.version || "unknown";
}
// SemVer build metadata (`+xyz`) is informational and ignored for precedence.
function stripBuildMetadata(version) {
return (version || "").split("+")[0];
}
async function checkForUpdate(currentVersion) {
try {
const res = await fetch(GITHUB_API_LATEST);
@@ -25,7 +23,7 @@ async function checkForUpdate(currentVersion) {
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
const current = stripBuildMetadata(currentVersion);
if (latest && latest !== current) {
if (isNewer(latest, current)) {
return { version: latest, url: data.html_url };
}
@@ -88,6 +86,7 @@ function display(containerEl, app) {
});
addServerStatus(containerEl);
addServerSettings(containerEl, app);
}
const STATUS_LABELS = {
@@ -102,10 +101,22 @@ const STATUS_DOT_CLASSES = {
closed: "ignis-status-disconnected",
};
function createSettingGroup(containerEl, heading) {
const group = containerEl.createDiv("setting-group");
if (heading) {
new Setting(group).setName(heading).setHeading();
}
return group.createDiv("setting-items");
}
function addServerStatus(containerEl) {
const ws = window.__ignis.ws;
const setting = new Setting(containerEl).setName("Server status");
const items = createSettingGroup(containerEl);
const setting = new Setting(items).setName("Server status");
const dotEl = setting.controlEl.createEl("span", {
cls: "ignis-status-dot",
@@ -138,4 +149,214 @@ function addServerStatus(containerEl) {
});
}
module.exports = { display };
const MB = 1024 * 1024;
const MINUTE = 60 * 1000;
function addServerSettings(containerEl, app) {
if (isDemoMode()) {
const items = createSettingGroup(containerEl);
new Setting(items)
.setName("Server settings")
.setDesc("Server settings are disabled in demo mode.");
return;
}
const loading = containerEl.createEl("p", {
text: "Loading server settings...",
cls: "setting-item-description",
});
fetch("/api/settings")
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
.then((current) => {
loading.remove();
renderServerSettings(containerEl, current, app);
})
.catch(() => {
loading.setText("Failed to load server settings.");
});
}
function renderServerSettings(containerEl, current, app) {
const caching = createSettingGroup(containerEl, "Caching");
numberField(caching, {
name: "Content cache (MB)",
desc: "Browser cache of file content. Applies after reload.",
value: Math.round(current.contentCacheBytes / MB),
key: "contentCacheBytes",
toStored: (n) => n * MB,
});
numberField(caching, {
name: "Input cache (MB)",
desc: "Cache for files picked for import. Applies after reload.",
value: Math.round(current.inputCacheBytes / MB),
key: "inputCacheBytes",
toStored: (n) => n * MB,
});
numberField(caching, {
name: "Input cache TTL (minutes)",
desc: "How long picked files stay cached. Applies after reload.",
value: Math.round(current.inputCacheTtlMs / MINUTE),
key: "inputCacheTtlMs",
toStored: (n) => n * MINUTE,
});
const security = createSettingGroup(containerEl, "Security");
numberField(security, {
name: "Max request body (MB)",
desc: "Largest request the server accepts.",
value: Math.round(current.maxBodyBytes / MB),
key: "maxBodyBytes",
toStored: (n) => n * MB,
});
proxyAccessField(security, current, app);
const advanced = createSettingGroup(containerEl, "Advanced");
numberField(advanced, {
name: "Write coalesce window (ms)",
desc: "Debounce window for rapid writes on slow filesystems. 0 disables.",
value: current.writeCoalesceMs,
key: "writeCoalesceMs",
toStored: (n) => n,
});
}
// Persist a single setting. The server validates, applies the live ones, and saves.
async function saveSetting(partial) {
try {
const res = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(partial),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Save failed");
}
} catch (e) {
new Notice(`Failed to save setting: ${e.message}`);
}
}
function numberField(containerEl, { name, desc, value, key, toStored }) {
let committed = value;
new Setting(containerEl)
.setName(name)
.setDesc(desc)
.addText((text) => {
text.setValue(String(value));
// Commit only on change.
const commit = () => {
const n = parseInt(text.getValue(), 10);
if (!Number.isInteger(n) || n < 0 || n === committed) {
return;
}
committed = n;
saveSetting({ [key]: toStored(n) });
};
text.inputEl.addEventListener("blur", commit);
text.inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
commit();
}
});
});
}
// Proxy access mode plus the allowlist row, which only shows in "allowlist" mode.
function proxyAccessField(parent, current, app) {
let mode = current.proxyMode || "any";
const setting = new Setting(parent)
.setName("Proxy access")
.setDesc(
"Which external hosts Obsidian may reach through the server's CORS proxy.",
);
const allowlistSetting = listField(parent, {
name: "Proxy host allowlist",
desc: "Hostnames the proxy may reach, matched exactly.",
value: current.proxyAllowlist,
key: "proxyAllowlist",
app,
modal: {
placeholder: "api.example.com",
emptyNote: "No hosts yet.",
recommended: {
note: "Restricting the proxy stops Obsidian's plugin and theme browser and updates from working unless their hosts are allowed.",
hosts: [
"releases.obsidian.md",
"github.com",
"api.github.com",
"raw.githubusercontent.com",
],
buttonText: "Add recommended hosts",
},
},
});
const applyVisibility = () => {
allowlistSetting.settingEl.style.display =
mode === "allowlist" ? "" : "none";
};
setting.addDropdown((dd) => {
dd.addOption("any", "Any public host");
dd.addOption("allowlist", "Allowlist only");
dd.addOption("disabled", "Disabled");
dd.setValue(mode);
dd.onChange((value) => {
mode = value;
saveSetting({ proxyMode: value });
applyVisibility();
});
});
applyVisibility();
}
function listField(containerEl, { name, desc, value, key, app, modal }) {
let current = [...(value || [])];
const setting = new Setting(containerEl).setName(name).setDesc(desc);
const setLabel = (btn) =>
btn.setButtonText(current.length ? `Edit (${current.length})` : "Edit");
setting.addButton((btn) => {
setLabel(btn);
btn.onClick(() => {
new ListEditorModal(app, {
title: name,
placeholder: modal.placeholder,
emptyNote: modal.emptyNote,
recommended: modal.recommended,
values: current,
onChange: (next) => {
current = next;
saveSetting({ [key]: current });
setLabel(btn);
},
}).open();
});
});
return setting;
}
export { display };

View File

@@ -1,14 +1,14 @@
const generalTab = require("./general-tab");
const serverPluginsTab = require("./server-plugins-tab");
const { createNavEl, createTab, createGroup } = require("./settings-ui");
const {
import * as generalTab from "./general-tab.js";
import * as serverPluginsTab from "./server-plugins-tab.js";
import { createNavEl, createTab, createGroup } from "./settings-ui.js";
import {
allIgnisNavEls,
setupPluginTabs,
reconcilePluginTabs,
hideIgnisFromCommunityPlugins,
restoreCommunityPlugins,
clearOwnedPluginIds,
} = require("./plugin-tabs");
} from "./plugin-tabs.js";
function removeExistingIgnisGroups(tabHeadersEl) {
const groups = tabHeadersEl.querySelectorAll(".vertical-tab-header-group");
@@ -139,4 +139,4 @@ function unpatchSettingsModal(plugin) {
clearOwnedPluginIds();
}
module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };
export { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };

View File

@@ -0,0 +1,134 @@
import { Modal, Setting, Notice } from "obsidian";
// Modal editor for a list of string entries (the proxy host allowlist).
class ListEditorModal extends Modal {
constructor(app, opts) {
super(app);
this.opts = opts;
this.values = [...(opts.values || [])];
}
onOpen() {
this.titleEl.setText(this.opts.title);
if (this.opts.recommended) {
new Setting(this.contentEl)
.setDesc(this.opts.recommended.note)
.addButton((btn) =>
btn
.setButtonText(
this.opts.recommended.buttonText || "Add recommended",
)
.onClick(() => this.addRecommended()),
);
}
this.listEl = this.contentEl.createDiv("ignis-list-editor");
this.renderList();
new Setting(this.contentEl)
.setName("Add entry")
.addText((text) => {
this.input = text;
text.setPlaceholder(this.opts.placeholder || "");
text.inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
this.addCurrent();
}
});
})
.addButton((btn) =>
btn
.setButtonText("Add")
.setCta()
.onClick(() => this.addCurrent()),
);
}
addEntry(entry) {
if (this.values.includes(entry)) {
return false;
}
this.values.push(entry);
return true;
}
addCurrent() {
const entry = this.input.getValue().trim();
if (!entry) {
return;
}
if (!this.addEntry(entry)) {
new Notice("That entry is already in the list.");
return;
}
this.input.setValue("");
this.input.inputEl.focus();
this.commit();
this.renderList();
}
addRecommended() {
let added = 0;
for (const host of this.opts.recommended.hosts) {
if (this.addEntry(host)) {
added++;
}
}
if (added > 0) {
this.commit();
this.renderList();
}
new Notice(
added > 0
? `Added ${added} host${added === 1 ? "" : "s"}.`
: "All recommended hosts are already in the list.",
);
}
remove(entry) {
this.values = this.values.filter((v) => v !== entry);
this.commit();
this.renderList();
}
renderList() {
this.listEl.empty();
if (this.values.length === 0) {
this.listEl.createDiv({
text: this.opts.emptyNote,
cls: "ignis-list-empty",
});
return;
}
for (const entry of this.values) {
new Setting(this.listEl).setName(entry).addExtraButton((btn) =>
btn
.setIcon("trash-2")
.setTooltip("Remove")
.onClick(() => this.remove(entry)),
);
}
}
commit() {
this.opts.onChange([...this.values]);
}
onClose() {
this.contentEl.empty();
}
}
export { ListEditorModal };

View File

@@ -1,6 +1,6 @@
const { setIcon } = require("obsidian");
const { findGroupByTitle } = require("./settings-ui");
const { isIgnisPlugin } = require("../plugin-registry");
import { setIcon } from "obsidian";
import { findGroupByTitle } from "./settings-ui.js";
import { isIgnisPlugin } from "../plugin-registry.js";
// All ignis-managed nav elements (both Ignis group and Ignis Core Plugins group).
// Shared with inject.js so the openTab patch can manage is-active across all of them.
@@ -232,7 +232,7 @@ function clearOwnedPluginIds() {
ownedPluginIds.clear();
}
module.exports = {
export {
allIgnisNavEls,
setupPluginTabs,
reconcilePluginTabs,

View File

@@ -1,5 +1,5 @@
const { Setting, Notice } = require("obsidian");
const { reconcilePluginTabs } = require("./plugin-tabs");
import { Setting, Notice } from "obsidian";
import { reconcilePluginTabs } from "./plugin-tabs.js";
function getVaultId() {
return window.__currentVaultId || "";
@@ -94,4 +94,4 @@ function display(containerEl, app) {
});
}
module.exports = { display };
export { display };

View File

@@ -1,4 +1,4 @@
const { setIcon } = require("obsidian");
import { setIcon } from "obsidian";
function createNavEl(tab, setting) {
const nav = document.createElement("div");
@@ -86,4 +86,4 @@ function findGroupByTitle(tabHeadersEl, title) {
return null;
}
module.exports = { createNavEl, createTab, createGroup, findGroupByTitle };
export { createNavEl, createTab, createGroup, findGroupByTitle };

View File

@@ -32,4 +32,4 @@ function initStatusBar(plugin) {
return ws.onStateChange(render);
}
module.exports = { initStatusBar };
export { initStatusBar };

View File

@@ -0,0 +1,39 @@
// Version comparison helpers for the update check.
// SemVer build metadata (`+xyz`) is informational and ignored for precedence.
function stripBuildMetadata(version) {
return (version || "").split("+")[0];
}
// Parse X.Y.Z to [major, minor, patch], or null when it isn't three integers.
function parseSemver(version) {
const parts = (version || "").split(".");
if (parts.length < 3) {
return null;
}
const nums = parts.slice(0, 3).map((p) => parseInt(p, 10));
return nums.some((n) => !Number.isInteger(n)) ? null : nums;
}
// True only when latest is strictly newer than current.
function isNewer(latest, current) {
const a = parseSemver(latest);
const b = parseSemver(current);
if (!a || !b) {
return false;
}
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) {
return a[i] > b[i];
}
}
return false;
}
export { stripBuildMetadata, parseSemver, isNewer };

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from "vitest";
import { stripBuildMetadata, isNewer } from "./version.js";
describe("isNewer", () => {
it("is true when latest is strictly newer", () => {
expect(isNewer("0.8.4", "0.8.3")).toBe(true);
expect(isNewer("1.0.0", "0.9.9")).toBe(true);
expect(isNewer("0.9.0", "0.8.9")).toBe(true);
});
it("is false for older or equal, so no downgrade is prompted", () => {
expect(isNewer("0.8.3", "0.8.4")).toBe(false);
expect(isNewer("0.8.4", "0.8.4")).toBe(false);
expect(isNewer("0.9.9", "1.0.0")).toBe(false);
});
it("is false for malformed versions", () => {
expect(isNewer("x", "0.8.4")).toBe(false);
expect(isNewer("0.8", "0.8.4")).toBe(false);
expect(isNewer("1.x.0", "0.8.4")).toBe(false);
});
it("ignores build metadata, so an equal version with a build tag is not newer", () => {
expect(
isNewer(stripBuildMetadata("0.8.4"), stripBuildMetadata("0.8.4+q2fmfox")),
).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
const { FuzzySuggestModal } = require("obsidian");
import { FuzzySuggestModal } from "obsidian";
class WorkspacePickerModal extends FuzzySuggestModal {
constructor(app) {
@@ -29,4 +29,4 @@ class WorkspacePickerModal extends FuzzySuggestModal {
}
}
module.exports = { WorkspacePickerModal };
export { WorkspacePickerModal };

View File

@@ -141,3 +141,18 @@
font-size: var(--font-ui-small);
margin-bottom: 16px;
}
.ignis-list-editor {
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
max-height: 220px;
overflow-y: auto;
padding: 0 var(--size-4-3);
margin-bottom: var(--size-4-4);
}
.ignis-list-empty {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
padding: var(--size-4-3) 0;
}

View File

@@ -2,6 +2,11 @@ const { WebSocketServer } = require("ws");
const url = require("url");
const watcher = require("./watcher");
// Null / undefined / empty array means no Origin check.
function toOriginSet(list) {
return Array.isArray(list) && list.length > 0 ? new Set(list) : null;
}
function setupWebSocket(server, opts = {}) {
const { getVaultPath, originAllowlist } = opts;
@@ -9,11 +14,7 @@ function setupWebSocket(server, opts = {}) {
throw new Error("setupWebSocket: opts.getVaultPath is required");
}
// Null / undefined / empty array = no Origin check.
const originSet =
Array.isArray(originAllowlist) && originAllowlist.length > 0
? new Set(originAllowlist)
: null;
const originSet = toOriginSet(originAllowlist);
const wss = new WebSocketServer({ server, path: "/ws" });

View File

@@ -1,6 +1,6 @@
import { showVaultManager } from "../ui-registry.js";
import { vaultService } from "@ignis/services";
import { arrayBufferToBase64, base64ToArrayBuffer } from "../util/base64.js";
import { proxyFetch } from "../util/proxy.js";
const listeners = new Map();
@@ -88,41 +88,19 @@ const syncHandlers = {
async function handleRequestUrl(requestId, request) {
try {
let body = request.body;
let binary = false;
if (body instanceof ArrayBuffer) {
body = arrayBufferToBase64(body);
binary = true;
}
const res = await fetch("/api/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: request.url,
method: request.method || "GET",
headers: request.headers || {},
contentType: request.contentType,
body,
binary,
}),
const result = await proxyFetch({
url: request.url,
method: request.method,
headers: request.headers,
body: request.body,
contentType: request.contentType,
});
const proxyResult = await res.json();
if (!res.ok) {
ipcRenderer._emit(requestId, {
error: proxyResult.error || "Proxy request failed",
});
return;
}
// Electron's e.reply(requestId, data) sends on the requestId channel
ipcRenderer._emit(requestId, {
status: proxyResult.status,
headers: proxyResult.headers,
body: base64ToArrayBuffer(proxyResult.body),
status: result.status,
headers: result.headers,
body: result.body,
});
} catch (e) {
ipcRenderer._emit(requestId, {

View File

@@ -0,0 +1,34 @@
const CALLBACK_METHODS = [
"stat",
"lstat",
"readdir",
"readFile",
"writeFile",
"appendFile",
"unlink",
"rename",
"mkdir",
"rmdir",
"rm",
"copyFile",
"access",
"utimes",
"chmod",
];
export function createFsCallbacks(fsPromises) {
const callbacks = {};
for (const name of CALLBACK_METHODS) {
callbacks[name] = function (...args) {
const callback = args.pop();
fsPromises[name](...args).then(
(result) => callback(null, result),
(err) => callback(err),
);
};
}
return callbacks;
}

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { createFsCallbacks } from "./callback.js";
describe("fs callbacks", () => {
it("resolves the promise result through the callback", async () => {
const fakePromises = { readFile: async (p) => `data:${p}` };
const cb = createFsCallbacks(fakePromises);
const result = await new Promise((resolve) =>
cb.readFile("/x", (err, data) => resolve([err, data])),
);
expect(result).toEqual([null, "data:/x"]);
});
it("passes a rejection to the callback as the error argument", async () => {
const boom = new Error("nope");
const fakePromises = {
stat: async () => {
throw boom;
},
};
const cb = createFsCallbacks(fakePromises);
const result = await new Promise((resolve) =>
cb.stat("/x", (err) => resolve(err)),
);
expect(result).toBe(boom);
});
it("forwards the arguments that precede the callback", async () => {
let received = null;
const fakePromises = {
mkdir: async (p, opts) => {
received = [p, opts];
},
};
const cb = createFsCallbacks(fakePromises);
await new Promise((resolve) =>
cb.mkdir("/d", { recursive: true }, () => resolve()),
);
expect(received).toEqual(["/d", { recursive: true }]);
});
});

View File

@@ -10,6 +10,14 @@ export class ContentCache {
this._maxSize = maxSize;
}
setMaxSize(maxSize) {
this._maxSize = maxSize;
while (this._currentSize > this._maxSize && this._cache.size > 0) {
this._evictOne();
}
}
has(path) {
return this._cache.has(this._normalize(path));
}

View File

@@ -6,6 +6,8 @@ import { createFsSync } from "./sync.js";
import { createFsWatch } from "./watch.js";
import { createWatcherClient } from "./watcher-client.js";
import { createFdOps } from "./fd.js";
import { createFsCallbacks } from "./callback.js";
import { realpath, realpathSync } from "./realpath.js";
import { constants } from "./constants.js";
import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js";
import { wsClient } from "../ws-client.js";
@@ -18,10 +20,13 @@ const fsSync = createFsSync(metadataCache, contentCache, transport);
const fsWatch = createFsWatch(transport);
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient);
const fdOps = createFdOps(metadataCache, contentCache, transport);
const fsCallbacks = createFsCallbacks(fsPromises);
export const fsShim = {
promises: fsPromises,
...fsCallbacks,
existsSync: fsSync.existsSync,
readFileSync: fsSync.readFileSync,
writeFileSync: fsSync.writeFileSync,
@@ -29,6 +34,18 @@ export const fsShim = {
accessSync: fsSync.accessSync,
statSync: fsSync.statSync,
readdirSync: fsSync.readdirSync,
lstatSync: fsSync.lstatSync,
mkdirSync: fsSync.mkdirSync,
rmdirSync: fsSync.rmdirSync,
rmSync: fsSync.rmSync,
renameSync: fsSync.renameSync,
copyFileSync: fsSync.copyFileSync,
appendFileSync: fsSync.appendFileSync,
utimesSync: fsSync.utimesSync,
chmodSync: fsSync.chmodSync,
realpath,
realpathSync,
open: fdOps.open,
openSync: fdOps.openSync,

View File

@@ -7,8 +7,8 @@
import { normalize } from "../util/path.js";
const MAX_SIZE = 200 * 1024 * 1024;
const TTL_MS = 5 * 60 * 1000;
let MAX_SIZE = 200 * 1024 * 1024;
let TTL_MS = 5 * 60 * 1000;
const cache = new Map(); // path -> { data, size, createdAt }
let currentSize = 0;
@@ -112,6 +112,20 @@ export function inputCacheClear() {
currentSize = 0;
}
export function setInputCacheLimits({ maxSize, ttlMs }) {
if (Number.isFinite(maxSize)) {
MAX_SIZE = maxSize;
while (currentSize > MAX_SIZE && cache.size > 0) {
evictOldest();
}
}
if (Number.isFinite(ttlMs)) {
TTL_MS = ttlMs;
}
}
export function isInputCachePath(path) {
const norm = normalize(path);
return norm.startsWith(".obsidian/imports/");

View File

@@ -1,6 +1,10 @@
import { markLocalOp } from "./echo-guard.js";
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
import { applyReadTransform, applyWriteTransform, resolvePath } from "./transforms.js";
import {
applyReadTransform,
applyWriteTransform,
resolvePath,
} from "./transforms.js";
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
export function createFsPromises(metadataCache, contentCache, transport) {
@@ -270,6 +274,10 @@ export function createFsPromises(metadataCache, contentCache, transport) {
}
},
async chmod() {
// No permission bits in the vault FS. No-op.
},
async open(path, flags) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
const resolved = resolvePath(path);

View File

@@ -0,0 +1,12 @@
export function realpathSync(path) {
return typeof path === "string" ? path : String(path);
}
export function realpath(path, options, callback) {
const cb = typeof options === "function" ? options : callback;
queueMicrotask(() => cb(null, realpathSync(path)));
}
realpath.native = realpath;
realpathSync.native = realpathSync;

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { realpath } from "./realpath.js";
describe("fs realpath shim", () => {
it("realpath invokes the callback with the path", async () => {
const result = await new Promise((resolve) =>
realpath("/a/b.md", (err, p) => resolve(p)),
);
expect(result).toBe("/a/b.md");
});
it("realpath accepts an options argument before the callback", async () => {
const result = await new Promise((resolve) =>
realpath("/a/b.md", "utf8", (err, p) => resolve(p)),
);
expect(result).toBe("/a/b.md");
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from "vitest";
import { createFsSync } from "./sync.js";
import { resolvePath } from "./transforms.js";
function makeDeps() {
const store = new Map();
const metadataCache = {
has: (p) => store.has(p),
get: (p) => (store.has(p) ? store.get(p) : null),
set: (p, m) => store.set(p, m),
delete: (p) => store.delete(p),
rename: (a, b) => {
if (store.has(a)) {
store.set(b, store.get(a));
store.delete(a);
}
},
toStat: (p) =>
store.has(p)
? {
type: store.get(p).type,
isDirectory: () => store.get(p).type === "directory",
isFile: () => store.get(p).type === "file",
}
: null,
readdir: () => [],
};
const contentCache = {
get: () => null,
set: vi.fn(),
delete: vi.fn(),
invalidate: vi.fn(),
};
const transport = {
mkdir: vi.fn(async () => {}),
rmdir: vi.fn(async () => {}),
rm: vi.fn(async () => {}),
rename: vi.fn(async () => {}),
copyFile: vi.fn(async () => {}),
appendFile: vi.fn(async () => {}),
utimes: vi.fn(async () => {}),
stat: vi.fn(async () => ({ type: "file", size: 1 })),
};
return { metadataCache, contentCache, transport, store };
}
describe("sync fs mutations", () => {
it("lstatSync mirrors statSync", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
deps.store.set(resolvePath("dir"), { type: "directory" });
expect(fs.lstatSync("dir").isDirectory()).toBe(true);
});
it("mkdirSync updates the cache and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
fs.mkdirSync("newdir", { recursive: true });
expect(deps.store.get("newdir")).toEqual({ type: "directory" });
expect(deps.transport.mkdir).toHaveBeenCalledWith("newdir", true);
});
it("rmSync deletes from the cache and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
const key = resolvePath("gone.md");
deps.store.set(key, { type: "file" });
fs.rmSync("gone.md", { recursive: true });
expect(deps.store.has(key)).toBe(false);
expect(deps.transport.rm).toHaveBeenCalled();
});
it("renameSync moves cache metadata and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
const from = resolvePath("a.md");
const to = resolvePath("b.md");
deps.store.set(from, { type: "file", size: 2 });
fs.renameSync("a.md", "b.md");
expect(deps.store.has(from)).toBe(false);
expect(deps.store.get(to)).toEqual({ type: "file", size: 2 });
expect(deps.transport.rename).toHaveBeenCalled();
});
it("copyFileSync optimistically mirrors source metadata and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
const srcKey = resolvePath("src.md");
const destKey = resolvePath("dest.md");
deps.store.set(srcKey, { type: "file", size: 9 });
fs.copyFileSync("src.md", "dest.md");
expect(deps.store.get(destKey)).toEqual({ type: "file", size: 9 });
expect(deps.transport.copyFile).toHaveBeenCalled();
});
it("utimesSync sets mtime and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
const key = resolvePath("note.md");
deps.store.set(key, { type: "file", mtime: 0 });
fs.utimesSync("note.md", 111, 222);
expect(deps.store.get(key).mtime).toBe(222);
expect(deps.transport.utimes).toHaveBeenCalled();
});
it("chmodSync is a no-op that does not throw", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
expect(() => fs.chmodSync("note.md", 0o644)).not.toThrow();
expect(fs.chmodSync("note.md", 0o644)).toBeUndefined();
});
});

View File

@@ -180,5 +180,147 @@ export function createFsSync(metadataCache, contentCache, transport) {
const entries = metadataCache.readdir(path);
return entries.map((e) => e.name);
},
lstatSync(path) {
// No symlinks in our context.
return this.statSync(path);
},
mkdirSync(path, options) {
const recursive =
typeof options === "object" ? !!options.recursive : !!options;
markLocalOp(path);
metadataCache.set(path, { type: "directory" });
transport.mkdir(path, recursive).catch((e) => {
console.error("[shim:fs] mkdirSync background create failed:", path, e);
});
},
rmdirSync(path) {
markLocalOp(path);
metadataCache.delete(path);
transport.rmdir(path).catch((e) => {
console.error("[shim:fs] rmdirSync background remove failed:", path, e);
});
},
rmSync(path, options) {
const recursive =
typeof options === "object" ? !!options.recursive : false;
const resolved = resolvePath(path);
markLocalOp(resolved);
metadataCache.delete(resolved);
contentCache.delete(resolved);
transport.rm(resolved, recursive).catch((e) => {
console.error(
"[shim:fs] rmSync background remove failed:",
resolved,
e,
);
});
},
renameSync(oldPath, newPath) {
const resolvedOld = resolvePath(oldPath);
const resolvedNew = resolvePath(newPath);
markLocalOp(resolvedOld);
markLocalOp(resolvedNew);
const content = contentCache.get(resolvedOld);
if (content !== null) {
contentCache.set(resolvedNew, content);
contentCache.delete(resolvedOld);
}
metadataCache.rename(resolvedOld, resolvedNew);
transport.rename(resolvedOld, resolvedNew).catch((e) => {
console.error(
"[shim:fs] renameSync background rename failed:",
resolvedOld,
e,
);
});
},
copyFileSync(src, dest) {
const resolvedSrc = resolvePath(src);
const resolvedDest = resolvePath(dest);
markLocalOp(resolvedDest);
// Optimistically mirror the source so a sync read right after sees it.
const content = contentCache.get(resolvedSrc);
if (content !== null) {
contentCache.set(resolvedDest, content);
}
const srcMeta = metadataCache.get(resolvedSrc);
if (srcMeta) {
metadataCache.set(resolvedDest, { ...srcMeta });
}
transport
.copyFile(src, resolvedDest)
.then(() => transport.stat(resolvedDest))
.then((meta) => metadataCache.set(resolvedDest, meta))
.catch((e) => {
console.error(
"[shim:fs] copyFileSync background copy failed:",
resolvedDest,
e,
);
});
},
appendFileSync(path, data) {
const resolved = resolvePath(path);
markLocalOp(resolved);
contentCache.invalidate(resolved);
transport
.appendFile(resolved, data)
.then(() => transport.stat(resolved))
.then((meta) => metadataCache.set(resolved, meta))
.catch((e) => {
console.error(
"[shim:fs] appendFileSync background append failed:",
resolved,
e,
);
});
},
utimesSync(path, atime, mtime) {
const resolved = resolvePath(path);
const meta = metadataCache.get(resolved);
if (meta) {
meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime();
metadataCache.set(resolved, meta);
}
transport.utimes(resolved, atime, mtime).catch((e) => {
console.error(
"[shim:fs] utimesSync background utimes failed:",
resolved,
e,
);
});
},
chmodSync() {
// The vault FS does not model permission bits. No-op.
},
};
}

View File

@@ -4,8 +4,8 @@ import {
unregisterPopupWindow,
} from "./electron/remote/window.js";
import { showVaultManager } from "./ui-registry.js";
import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js";
import { isSameOrigin } from "./util/url.js";
import { proxyFetch } from "./util/proxy.js";
function installProcess() {
window.process = processShim;
@@ -167,17 +167,15 @@ function installFetchShim() {
}
let body = null;
let binary = false;
if (init?.body && method !== "GET" && method !== "HEAD") {
if (typeof init.body === "string") {
body = init.body;
} else if (init.body instanceof ArrayBuffer) {
body = arrayBufferToBase64(init.body);
binary = true;
} else if (init.body instanceof Uint8Array) {
body = arrayBufferToBase64(init.body.buffer);
binary = true;
} else if (
init.body instanceof ArrayBuffer ||
init.body instanceof Uint8Array
) {
body = init.body;
} else if (typeof init.body === "object") {
body = JSON.stringify(init.body);
} else {
@@ -187,23 +185,15 @@ function installFetchShim() {
console.log("[shim:fetch] Proxying cross-origin:", method, url);
const proxyRes = await originalFetch("/api/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, method, headers, body, binary }),
});
let result;
if (!proxyRes.ok) {
const err = await proxyRes
.json()
.catch(() => ({ error: "Proxy request failed" }));
throw new TypeError(err.error || "Failed to fetch");
try {
result = await proxyFetch({ url, method, headers, body });
} catch (e) {
throw new TypeError(e.message || "Failed to fetch");
}
const result = await proxyRes.json();
const respBody = base64ToArrayBuffer(result.body);
return new Response(respBody, {
return new Response(result.body, {
status: result.status,
headers: result.headers,
});
@@ -238,7 +228,12 @@ function installContextMenuFix() {
);
}
function installGlobalAlias() {
window.global = window;
}
export function installGlobals() {
installGlobalAlias();
installProcess();
installBuffer();
installFetchShim();

View File

@@ -8,11 +8,26 @@ import {
initWorkspacePatch,
} from "./workspace.js";
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
import { setInputCacheLimits } from "./fs/input-cache.js";
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
import { initNativeMenuGuard } from "./native-menu-guard.js";
let bootstrapVirtualPlugins = [];
// Cache sizes come from the bootstrap response and are applied at page load.
// The server owns the rest of the settings and applies them on its side.
function applyServerSettings(s) {
if (!s) {
return;
}
if (Number.isFinite(s.contentCacheBytes)) {
fsShim._contentCache.setMaxSize(s.contentCacheBytes);
}
setInputCacheLimits({ maxSize: s.inputCacheBytes, ttlMs: s.inputCacheTtlMs });
}
export function getBootstrapVirtualPlugins() {
return bootstrapVirtualPlugins;
}
@@ -212,6 +227,7 @@ export function initialize() {
applyTree(bootstrap.tree);
applyCoreSyncGuard(bootstrap.plugins);
bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
applyServerSettings(bootstrap.settings);
// Race the indexer: batch-fetch text content into ContentCache so
// Obsidian's startup indexing reads hit the cache instead of the network.

View File

@@ -52,7 +52,7 @@ if (window.__currentVaultId) {
extractObsidianModule()
.then(async () => {
// Dynamic import so bridge's top-level require("obsidian") fires after installRequire + extractObsidianModule.
// Dynamic import so the bridge's top-level obsidian import resolves after installRequire + extractObsidianModule.
const mod = await import("@ignis/bridge");
const IgnisBridgePlugin = mod.default || mod;
const bridge = new IgnisBridgePlugin(window.app, BRIDGE_MANIFEST);

View File

@@ -49,7 +49,8 @@ function readTransform(data) {
try {
const obj = JSON.parse(text);
if (obj.nativeMenus) {
// force native menus to false since its never appropriate in a browser context.
if (obj.nativeMenus !== false) {
obj.nativeMenus = false;
return JSON.stringify(obj);
}
@@ -100,6 +101,9 @@ function patchSetConfig() {
};
vault.__ignisNativeMenuGuarded = true;
// set to false to override any platform default (like macOS).
vault.setConfig("nativeMenus", false);
return true;
};

View File

@@ -0,0 +1,82 @@
class AssertionError extends Error {
constructor(message) {
super(message || "Assertion failed");
this.name = "AssertionError";
}
}
function assert(value, message) {
if (!value) {
throw new AssertionError(message);
}
}
assert.AssertionError = AssertionError;
assert.ok = assert;
assert.strict = assert;
assert.fail = function (message) {
throw new AssertionError(message || "Failed");
};
assert.equal = function (actual, expected, message) {
if (actual != expected) {
throw new AssertionError(message || `${actual} == ${expected}`);
}
};
assert.notEqual = function (actual, expected, message) {
if (actual == expected) {
throw new AssertionError(message || `${actual} != ${expected}`);
}
};
assert.strictEqual = function (actual, expected, message) {
if (actual !== expected) {
throw new AssertionError(message || `${actual} === ${expected}`);
}
};
assert.notStrictEqual = function (actual, expected, message) {
if (actual === expected) {
throw new AssertionError(message || `${actual} !== ${expected}`);
}
};
assert.deepEqual = function (actual, expected, message) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new AssertionError(message || "deepEqual");
}
};
assert.deepStrictEqual = assert.deepEqual;
assert.throws = function (fn, message) {
let threw = false;
try {
fn();
} catch {
threw = true;
}
if (!threw) {
throw new AssertionError(message || "Missing expected exception");
}
};
assert.doesNotThrow = function (fn, message) {
try {
fn();
} catch (e) {
throw new AssertionError(message || `Got unwanted exception: ${e.message}`);
}
};
assert.ifError = function (value) {
if (value) {
throw new AssertionError(`ifError got unwanted exception: ${value}`);
}
};
export const assertShim = assert;

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { assertShim as assert } from "./assert.js";
describe("assert shim", () => {
it("is callable and throws on a falsy value", () => {
expect(() => assert(false)).toThrow();
expect(() => assert(true)).not.toThrow();
});
it("equal throws on mismatch and passes on loose match", () => {
expect(() => assert.equal(1, 2)).toThrow();
expect(() => assert.equal(1, 1)).not.toThrow();
expect(() => assert.equal(1, "1")).not.toThrow();
});
it("strictEqual distinguishes type", () => {
expect(() => assert.strictEqual(1, "1")).toThrow();
expect(() => assert.strictEqual(1, 1)).not.toThrow();
});
it("throws() verifies that a function threw", () => {
expect(() =>
assert.throws(() => {
throw new Error("x");
}),
).not.toThrow();
expect(() => assert.throws(() => {})).toThrow();
});
});

View File

@@ -0,0 +1,50 @@
// Linux constant values, to match the platform the process shim reports.
// O_SYMLINK and other macOS/BSD flags are omitted so feature checks treat platform as linux
export const constantsShim = {
// File access checks (fs.access mode).
F_OK: 0,
X_OK: 1,
W_OK: 2,
R_OK: 4,
// open() flags.
O_RDONLY: 0,
O_WRONLY: 1,
O_RDWR: 2,
O_CREAT: 64,
O_EXCL: 128,
O_NOCTTY: 256,
O_TRUNC: 512,
O_APPEND: 1024,
O_DIRECTORY: 65536,
O_NOATIME: 262144,
O_NOFOLLOW: 131072,
O_SYNC: 1052672,
O_DSYNC: 4096,
O_NONBLOCK: 2048,
// File type bits (st_mode & S_IFMT).
S_IFMT: 61440,
S_IFREG: 32768,
S_IFDIR: 16384,
S_IFCHR: 8192,
S_IFBLK: 24576,
S_IFIFO: 4096,
S_IFLNK: 40960,
S_IFSOCK: 49152,
// Permission bits.
S_IRWXU: 448,
S_IRUSR: 256,
S_IWUSR: 128,
S_IXUSR: 64,
S_IRWXG: 56,
S_IRGRP: 32,
S_IWGRP: 16,
S_IXGRP: 8,
S_IRWXO: 7,
S_IROTH: 4,
S_IWOTH: 2,
S_IXOTH: 1,
};

View File

@@ -0,0 +1,85 @@
import { EventEmitter } from "./events.js";
let warned = false;
function warnNoDataFlow(method) {
if (warned) {
return;
}
warned = true;
console.warn(
`[shim:stream] ${method}() called, but stream data flow is not implemented. ` +
"This plugin needs the full stream shim.",
);
}
export class Stream extends EventEmitter {
pipe(destination) {
warnNoDataFlow("pipe");
return destination;
}
}
export class Readable extends Stream {
constructor(options) {
super();
this.readable = true;
this._readableState = { options: options || {} };
}
read() {
warnNoDataFlow("read");
return null;
}
push() {
warnNoDataFlow("push");
return false;
}
_read() {}
}
export class Writable extends Stream {
constructor(options) {
super();
this.writable = true;
this._writableState = { options: options || {} };
}
write() {
warnNoDataFlow("write");
return false;
}
end() {
warnNoDataFlow("end");
return this;
}
_write() {}
}
export class Duplex extends Readable {
constructor(options) {
super(options);
this.writable = true;
}
write() {
warnNoDataFlow("write");
return false;
}
end() {
warnNoDataFlow("end");
return this;
}
}
export class Transform extends Duplex {
_transform() {}
}
export class PassThrough extends Transform {}

View File

@@ -0,0 +1,16 @@
import { describe, it, expect, vi } from "vitest";
import { Readable, Writable } from "./stream.js";
describe("stream shim", () => {
it("warns once when data-flow methods are used", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
new Readable().read();
new Writable().write("x");
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain("[shim:stream]");
warn.mockRestore();
});
});

View File

@@ -1,5 +1,6 @@
export const processShim = {
platform: "linux",
version: "v18.18.0",
versions: {
electron: "28.2.3",
node: "18.18.0",

View File

@@ -2,7 +2,7 @@
// Obsidian sets window.requestUrl in app.js, so we override it after app.js loads.
import { isSameOrigin } from "./util/url.js";
import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js";
import { proxyFetch } from "./util/proxy.js";
async function proxyRequestUrl(request) {
if (typeof request === "string") {
@@ -28,42 +28,14 @@ async function proxyRequestUrl(request) {
}
// Cross-origin: route through server proxy
let body = request.body;
let binary = false;
if (body instanceof ArrayBuffer) {
body = arrayBufferToBase64(body);
binary = true;
}
const res = await fetch("/api/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: request.url,
method: request.method || "GET",
headers: request.headers || {},
body,
binary,
}),
const result = await proxyFetch({
url: request.url,
method: request.method,
headers: request.headers,
body: request.body,
});
if (!res.ok) {
const err = await res
.json()
.catch(() => ({ error: "Proxy request failed" }));
throw new Error(err.error);
}
const proxyResult = await res.json();
const arrayBuf = base64ToArrayBuffer(proxyResult.body);
return makeResponse(
request,
proxyResult.status,
proxyResult.headers,
arrayBuf,
);
return makeResponse(request, result.status, result.headers, result.body);
}
function makeResponse(request, status, headers, arrayBuf) {

View File

@@ -11,6 +11,9 @@ import * as netShim from "./node/net.js";
import * as httpShim from "./node/http.js";
import * as zlibShim from "./node/zlib.js";
import * as utilShim from "./node/util.js";
import { constantsShim } from "./node/constants.js";
import { assertShim } from "./node/assert.js";
import * as streamShim from "./node/stream.js";
import { wrapWithProxy, installDebugHelpers } from "./debug.js";
const rawRegistry = {
@@ -29,6 +32,9 @@ const rawRegistry = {
https: httpShim,
zlib: zlibShim,
util: utilShim,
constants: constantsShim,
assert: assertShim,
stream: streamShim,
};
const shimRegistry = {};

View File

@@ -0,0 +1,54 @@
// Single round-trip through the server's /api/proxy endpoint for cross-origin requests.
// Encodes a binary request body to base64, returns the upstream response with its body as an ArrayBuffer.
// Throws an Error carrying the server's message on failure.
import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
export async function proxyFetch({ url, method, headers, body, contentType }) {
let encodedBody = null;
let binary = false;
if (body instanceof ArrayBuffer) {
encodedBody = arrayBufferToBase64(body);
binary = true;
} else if (body instanceof Uint8Array) {
encodedBody = arrayBufferToBase64(body.buffer);
binary = true;
} else if (body != null) {
encodedBody = body;
}
const payload = {
url,
method: method || "GET",
headers: headers || {},
body: encodedBody,
binary,
};
if (contentType !== undefined) {
payload.contentType = contentType;
}
// Use native fetch to avoid an unnecessary call through the shim. proxy is already same origin.
const nativeFetch = window.__originalFetch || fetch;
const res = await nativeFetch("/api/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || "Proxy request failed");
}
const result = await res.json();
return {
status: result.status,
headers: result.headers,
body: base64ToArrayBuffer(result.body),
};
}