rewrite transforms, implement unified transform layer

This commit is contained in:
Nystik
2026-05-15 03:42:56 +02:00
parent d8c43c20f4
commit 47d39098cd
10 changed files with 289 additions and 206 deletions

View File

@@ -51,6 +51,16 @@ Writes go through a server-side write coalescer (`server/write-coalescer.js`) de
Sync calls use synchronous XHR to ensure blocking behavior. Async calls use fetch. Everything goes through a transport layer that handles vault ID injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values.
### Translation registry
The shim has a registry (`src/shims/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types:
- **Path resolvers** map a logical path to a physical path. Used by the workspaces shim to redirect reads and writes of `.obsidian/workspace.json` to `.obsidian/workspace.<name>.json` based on the `?workspace=` URL parameter, so each browser tab can hold a separate layout.
- **Read transforms** post-process bytes returned by a read (cache hit or transport miss). Used to mask the Obsidian Sync setting in `core-plugins.json` when headless-sync is active for the vault, and to override the `active` field on reads of `workspaces.json` so each tab sees its own workspace as selected.
- **Write transforms** pre-process bytes before a write hits the cache or transport. Used to override the `active` field on writes to `workspaces.json` so cross-tab disk state stays canonical.
All hooks are synchronous and registered at module load. Translation happens once at the shim entry; downstream layers (content cache, metadata cache, transport) operate only on resolved physical paths and as-stored bytes. This keeps cache keys coherent with what transport actually reads and writes, so prefetch and on-demand fetches share the same cache slot.
### IPC
IPC is implemented as a synchronous dispatcher that maps channel names to handlers.

View File

@@ -3,6 +3,7 @@
// around files without loading them via readFileSync upfront.
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
import { resolvePath } from "./transforms.js";
let nextFd = 100;
const openFiles = new Map();
@@ -22,7 +23,8 @@ export function createFdOps(metadataCache, contentCache, transport) {
}
}
const cached = contentCache.get(path);
const resolved = resolvePath(path);
const cached = contentCache.get(resolved);
if (cached !== null) {
if (typeof cached === "string") {
@@ -33,9 +35,9 @@ export function createFdOps(metadataCache, contentCache, transport) {
}
// Synchronous fetch fallback
console.warn("[shim:fs] fd open cache miss, using sync XHR:", path);
const data = transport.readFileSync(path);
contentCache.set(path, data);
console.warn("[shim:fs] fd open cache miss, using sync XHR:", resolved);
const data = transport.readFileSync(resolved);
contentCache.set(resolved, data);
return data;
}
@@ -56,8 +58,9 @@ export function createFdOps(metadataCache, contentCache, transport) {
function openSync(path, flags, mode) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
const resolved = resolvePath(path);
if (!hasInCache && !metadataCache.has(path)) {
if (!hasInCache && !metadataCache.has(resolved)) {
const err = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);
@@ -67,7 +70,7 @@ export function createFdOps(metadataCache, contentCache, transport) {
const data = ensureData(path);
const fd = nextFd++;
openFiles.set(fd, { path, data });
openFiles.set(fd, { path: resolved, data });
return fd;
}

View File

@@ -7,7 +7,7 @@ import { createFsWatch } from "./watch.js";
import { createWatcherClient } from "./watcher-client.js";
import { createFdOps } from "./fd.js";
import { constants } from "./constants.js";
import { registerReadTransform, removeReadTransform } from "./read-transforms.js";
import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js";
const metadataCache = new MetadataCache();
const contentCache = new ContentCache();
@@ -41,6 +41,10 @@ export const fsShim = {
watch: fsWatch.watch,
constants,
invalidate(path) {
contentCache.invalidate(resolvePath(path));
},
_metadataCache: metadataCache,
_contentCache: contentCache,
_watcherClient: watcherClient,

View File

@@ -1,19 +1,20 @@
import { markLocalOp } from "./echo-guard.js";
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
import { applyReadTransform } from "./read-transforms.js";
import { applyReadTransform, applyWriteTransform, resolvePath } from "./transforms.js";
export function createFsPromises(metadataCache, contentCache, transport) {
return {
async stat(path) {
const cached = metadataCache.toStat(path);
const resolved = resolvePath(path);
const cached = metadataCache.toStat(resolved);
if (cached) {
return cached;
}
const meta = await transport.stat(path);
metadataCache.set(path, meta);
return metadataCache.toStat(path);
const meta = await transport.stat(resolved);
metadataCache.set(resolved, meta);
return metadataCache.toStat(resolved);
},
async lstat(path) {
@@ -46,6 +47,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
}
const wantText = encoding === "utf8" || encoding === "utf-8";
const resolved = resolvePath(path);
let result = null;
@@ -55,7 +57,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
}
if (result === null) {
const meta = metadataCache.get(path);
const meta = metadataCache.get(resolved);
if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read");
@@ -63,7 +65,8 @@ export function createFsPromises(metadataCache, contentCache, transport) {
throw e;
}
if (!meta && path) {
if (!meta && resolved && resolved === path) {
// Throw ENOENT only when not redirected; redirected paths fall through to the transport's fallback.
const e = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);
@@ -71,16 +74,25 @@ export function createFsPromises(metadataCache, contentCache, transport) {
throw e;
}
result = contentCache.get(path);
result = contentCache.get(resolved);
}
if (result === null) {
result = await transport.readFile(path, encoding);
contentCache.set(path, result);
try {
result = await transport.readFile(resolved, encoding);
} catch (e) {
if (resolved !== path && e.code === "ENOENT") {
result = await transport.readFile(path, encoding);
} else {
throw e;
}
}
contentCache.set(resolved, result);
}
// Apply registered read transforms (e.g., patching synced config files).
result = applyReadTransform(path, result);
result = applyReadTransform(resolved, result);
if (wantText) {
return typeof result === "string"
@@ -100,62 +112,74 @@ export function createFsPromises(metadataCache, contentCache, transport) {
encoding = encoding?.encoding;
}
markLocalOp(path);
contentCache.set(path, data);
const resolved = resolvePath(path);
const transformed = applyWriteTransform(resolved, data);
markLocalOp(resolved);
contentCache.set(resolved, transformed);
const size =
typeof data === "string" ? data.length : data.byteLength || 0;
typeof transformed === "string"
? transformed.length
: transformed.byteLength || 0;
metadataCache.set(path, {
metadataCache.set(resolved, {
type: "file",
size,
mtime: Date.now(),
ctime: metadataCache.get(path)?.ctime || Date.now(),
ctime: metadataCache.get(resolved)?.ctime || Date.now(),
});
const result = await transport.writeFile(path, data, encoding);
const result = await transport.writeFile(resolved, transformed, encoding);
if (result.mtime) {
metadataCache.set(path, {
metadataCache.set(resolved, {
type: "file",
size: result.size || size,
mtime: result.mtime,
ctime: metadataCache.get(path)?.ctime || Date.now(),
ctime: metadataCache.get(resolved)?.ctime || Date.now(),
});
}
},
async appendFile(path, data, encoding) {
markLocalOp(path);
contentCache.invalidate(path);
const resolved = resolvePath(path);
await transport.appendFile(path, data);
markLocalOp(resolved);
contentCache.invalidate(resolved);
const meta = await transport.stat(path);
metadataCache.set(path, meta);
await transport.appendFile(resolved, data);
const meta = await transport.stat(resolved);
metadataCache.set(resolved, meta);
},
async unlink(path) {
markLocalOp(path);
contentCache.delete(path);
metadataCache.delete(path);
const resolved = resolvePath(path);
await transport.unlink(path);
markLocalOp(resolved);
contentCache.delete(resolved);
metadataCache.delete(resolved);
await transport.unlink(resolved);
},
async rename(oldPath, newPath) {
markLocalOp(oldPath);
markLocalOp(newPath);
const content = contentCache.get(oldPath);
const resolvedOld = resolvePath(oldPath);
const resolvedNew = resolvePath(newPath);
markLocalOp(resolvedOld);
markLocalOp(resolvedNew);
const content = contentCache.get(resolvedOld);
if (content !== null) {
contentCache.set(newPath, content);
contentCache.delete(oldPath);
contentCache.set(resolvedNew, content);
contentCache.delete(resolvedOld);
}
metadataCache.rename(oldPath, newPath);
metadataCache.rename(resolvedOld, resolvedNew);
await transport.rename(oldPath, newPath);
await transport.rename(resolvedOld, resolvedNew);
},
async mkdir(path, options) {
@@ -178,23 +202,29 @@ export function createFsPromises(metadataCache, contentCache, transport) {
const recursive =
typeof options === "object" ? !!options.recursive : false;
markLocalOp(path);
metadataCache.delete(path);
contentCache.delete(path);
const resolved = resolvePath(path);
await transport.rm(path, recursive);
markLocalOp(resolved);
metadataCache.delete(resolved);
contentCache.delete(resolved);
await transport.rm(resolved, recursive);
},
async copyFile(src, dest) {
markLocalOp(dest);
await transport.copyFile(src, dest);
const resolvedDest = resolvePath(dest);
const meta = await transport.stat(dest);
metadataCache.set(dest, meta);
markLocalOp(resolvedDest);
await transport.copyFile(src, resolvedDest);
const meta = await transport.stat(resolvedDest);
metadataCache.set(resolvedDest, meta);
},
async access(path) {
if (metadataCache.has(path)) {
const resolved = resolvePath(path);
if (metadataCache.has(resolved)) {
return;
}
@@ -214,18 +244,21 @@ export function createFsPromises(metadataCache, contentCache, transport) {
},
async utimes(path, atime, mtime) {
await transport.utimes(path, atime, mtime);
const meta = metadataCache.get(path);
const resolved = resolvePath(path);
await transport.utimes(resolved, atime, mtime);
const meta = metadataCache.get(resolved);
if (meta) {
meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime();
metadataCache.set(path, meta);
metadataCache.set(resolved, meta);
}
},
async open(path, flags) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
const resolved = resolvePath(path);
if (!hasInCache && !metadataCache.has(path)) {
if (!hasInCache && !metadataCache.has(resolved)) {
const err = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);
@@ -237,7 +270,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
const fileData =
typeof data === "string" ? new TextEncoder().encode(data) : data;
const fileStat = metadataCache.toStat(path) || {
const fileStat = metadataCache.toStat(resolved) || {
size: fileData.length,
isFile: () => true,
isDirectory: () => false,

View File

@@ -1,39 +0,0 @@
// Post-read transforms for specific file paths.
// Allows patching file content after reading but before returning to the caller.
// Used to prevent synced config files from activating conflicting features.
const transforms = new Map();
function normalize(p) {
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
export function registerReadTransform(path, fn) {
transforms.set(normalize(path), fn);
}
export function removeReadTransform(path) {
transforms.delete(normalize(path));
}
export function applyReadTransform(path, data) {
const norm = normalize(path);
const fn = transforms.get(norm);
if (!fn) {
return data;
}
try {
return fn(data);
} catch {
return data;
}
}
export function hasReadTransform(path) {
return transforms.has(normalize(path));
}

View File

@@ -1,6 +1,10 @@
import { markLocalOp } from "./echo-guard.js";
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
import { applyReadTransform } from "./read-transforms.js";
import {
applyReadTransform,
applyWriteTransform,
resolvePath,
} from "./transforms.js";
export function createFsSync(metadataCache, contentCache, transport) {
return {
@@ -9,7 +13,8 @@ export function createFsSync(metadataCache, contentCache, transport) {
return true;
}
return metadataCache.has(path);
const resolved = resolvePath(path);
return metadataCache.has(resolved);
},
statSync(path) {
@@ -27,7 +32,8 @@ export function createFsSync(metadataCache, contentCache, transport) {
};
}
const stat = metadataCache.toStat(path);
const resolved = resolvePath(path);
const stat = metadataCache.toStat(resolved);
if (!stat) {
const err = new Error(
@@ -45,7 +51,9 @@ export function createFsSync(metadataCache, contentCache, transport) {
return;
}
if (!metadataCache.has(path)) {
const resolved = resolvePath(path);
if (!metadataCache.has(resolved)) {
const err = new Error(
`ENOENT: no such file or directory, access '${path}'`,
);
@@ -60,8 +68,9 @@ export function createFsSync(metadataCache, contentCache, transport) {
}
const wantText = encoding === "utf8" || encoding === "utf-8";
const resolved = resolvePath(path);
const meta = metadataCache.get(path);
const meta = metadataCache.get(resolved);
if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read");
e.code = "EISDIR";
@@ -80,17 +89,31 @@ export function createFsSync(metadataCache, contentCache, transport) {
}
if (result === null) {
result = contentCache.get(path);
result = contentCache.get(resolved);
}
if (result === null) {
console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path);
result = transport.readFileSync(path, encoding);
contentCache.set(path, result);
// ENOENT fallback: if the resolved path doesn't exist, try the original.
// Covers per-name workspace files that haven't been saved yet.
try {
result = transport.readFileSync(resolved, encoding);
} catch (e) {
if (resolved !== path && e.code === "ENOENT") {
console.warn(
"[shim:fs] readFileSync cache miss, using sync XHR:",
path,
);
result = transport.readFileSync(path, encoding);
} else {
throw e;
}
}
contentCache.set(resolved, result);
}
// Apply registered read transforms (e.g., patching synced config files).
result = applyReadTransform(path, result);
result = applyReadTransform(resolved, result);
if (wantText) {
return typeof result === "string"
@@ -106,40 +129,47 @@ export function createFsSync(metadataCache, contentCache, transport) {
encoding = encoding?.encoding;
}
markLocalOp(path);
contentCache.set(path, data);
const resolved = resolvePath(path);
const transformed = applyWriteTransform(resolved, data);
markLocalOp(resolved);
contentCache.set(resolved, transformed);
const size =
typeof data === "string" ? data.length : data.byteLength || 0;
typeof transformed === "string"
? transformed.length
: transformed.byteLength || 0;
metadataCache.set(path, {
metadataCache.set(resolved, {
type: "file",
size,
mtime: Date.now(),
ctime: metadataCache.get(path)?.ctime || Date.now(),
ctime: metadataCache.get(resolved)?.ctime || Date.now(),
});
// Fire-and-forget async send to server
transport.writeFile(path, data, encoding).catch((e) => {
transport.writeFile(resolved, transformed, encoding).catch((e) => {
console.error(
"[shim:fs] writeFileSync background save failed:",
path,
resolved,
e,
);
});
},
unlinkSync(path) {
markLocalOp(path);
contentCache.delete(path);
metadataCache.delete(path);
const resolved = resolvePath(path);
markLocalOp(resolved);
contentCache.delete(resolved);
metadataCache.delete(resolved);
// Fire-and-forget. suppress ENOENT (file already gone)
transport.unlink(path).catch((e) => {
transport.unlink(resolved).catch((e) => {
if (e.code !== "ENOENT") {
console.error(
"[shim:fs] unlinkSync background delete failed:",
path,
resolved,
e,
);
}

View File

@@ -0,0 +1,89 @@
// FS shim translation registry.
// Path resolvers map logical paths to physical paths; read transforms post-process bytes after a read; write transforms pre-process bytes before a write.
// All hooks run at the shim's public surface, so caches and transport see only physical paths and as-stored bytes.
function normalize(p) {
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
}
// --- Path resolvers ---
const pathResolvers = [];
export function registerPathResolver(matcher, resolver) {
pathResolvers.push({ matcher, resolver });
}
export function resolvePath(path) {
const norm = normalize(path);
for (const { matcher, resolver } of pathResolvers) {
try {
if (matcher(norm)) {
const resolved = resolver(norm);
if (typeof resolved === "string" && resolved.length > 0) {
return resolved;
}
}
} catch {}
}
return norm;
}
// --- Read transforms ---
const readTransforms = new Map();
export function registerReadTransform(path, fn) {
readTransforms.set(normalize(path), fn);
}
export function removeReadTransform(path) {
readTransforms.delete(normalize(path));
}
export function applyReadTransform(path, data) {
const fn = readTransforms.get(normalize(path));
if (!fn) {
return data;
}
try {
return fn(data);
} catch {
return data;
}
}
export function hasReadTransform(path) {
return readTransforms.has(normalize(path));
}
// --- Write transforms ---
const writeTransforms = new Map();
export function registerWriteTransform(path, fn) {
writeTransforms.set(normalize(path), fn);
}
export function removeWriteTransform(path) {
writeTransforms.delete(normalize(path));
}
export function applyWriteTransform(path, data) {
const fn = writeTransforms.get(normalize(path));
if (!fn) {
return data;
}
try {
return fn(data);
} catch {
return data;
}
}

View File

@@ -1,10 +1,4 @@
import {
rewriteWorkspacePath,
rewriteWorkspacesContent,
} from "../workspace.js";
const API_BASE = "/api/fs";
const WORKSPACES_PATH = ".obsidian/workspaces.json";
function normPath(p) {
return (p || "").replace(/^\/+/, "");
@@ -116,26 +110,10 @@ export const transport = {
},
async readFile(path, encoding) {
const norm = normPath(path);
const rewritten = rewriteWorkspacePath(norm);
let res;
try {
res = await request("GET", "/readFile", {
path: rewritten,
encoding: encoding || "",
});
} catch (e) {
if (rewritten !== norm && e.code === "ENOENT") {
res = await request("GET", "/readFile", {
path: norm,
encoding: encoding || "",
});
} else {
throw e;
}
}
const res = await request("GET", "/readFile", {
path: normPath(path),
encoding: encoding || "",
});
if (encoding === "utf8" || encoding === "utf-8") {
return res.text();
@@ -146,17 +124,10 @@ export const transport = {
},
async writeFile(path, content, encoding) {
const norm = normPath(path);
let data = content;
if (norm === WORKSPACES_PATH && typeof data === "string") {
data = rewriteWorkspacesContent(data);
}
const isText = typeof data === "string";
const isText = typeof content === "string";
return requestJson("POST", "/writeFile", {
path: rewriteWorkspacePath(norm),
content: isText ? data : uint8ToBase64(data),
path: normPath(path),
content: isText ? content : uint8ToBase64(content),
encoding: encoding || (isText ? "utf-8" : "binary"),
base64: !isText,
});
@@ -222,26 +193,10 @@ export const transport = {
},
readFileSync(path, encoding) {
const norm = normPath(path);
const rewritten = rewriteWorkspacePath(norm);
let xhr;
try {
xhr = requestSync("GET", "/readFile", {
path: rewritten,
encoding: encoding || "",
});
} catch (e) {
if (rewritten !== norm && e.code === "ENOENT") {
xhr = requestSync("GET", "/readFile", {
path: norm,
encoding: encoding || "",
});
} else {
throw e;
}
}
const xhr = requestSync("GET", "/readFile", {
path: normPath(path),
encoding: encoding || "",
});
if (encoding === "utf8" || encoding === "utf-8") {
return xhr.responseText;
@@ -258,17 +213,10 @@ export const transport = {
},
writeFileSync(path, content, encoding) {
const norm = normPath(path);
let data = content;
if (norm === WORKSPACES_PATH && typeof data === "string") {
data = rewriteWorkspacesContent(data);
}
const isText = typeof data === "string";
const isText = typeof content === "string";
requestSync("POST", "/writeFile", {
path: rewriteWorkspacePath(norm),
content: isText ? data : uint8ToBase64(data),
path: normPath(path),
content: isText ? content : uint8ToBase64(content),
encoding: encoding || (isText ? "utf-8" : "binary"),
base64: !isText,
});

View File

@@ -2,7 +2,7 @@ import { fsShim } from "./fs/index.js";
import { installRequestUrlShim } from "./request-url.js";
import { vaultService } from "../services/vault-service.js";
import { showPluginInstallDialog } from "../ui/bootstrap.js";
import { registerReadTransform } from "./fs/read-transforms.js";
import { registerReadTransform } from "./fs/transforms.js";
import { resolveWorkspaceName, initWorkspacePatch } from "./workspace.js";
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";

View File

@@ -1,30 +1,31 @@
import { fsShim } from "./fs/index.js";
import { registerReadTransform } from "./fs/read-transforms.js";
import {
registerPathResolver,
registerReadTransform,
registerWriteTransform,
} from "./fs/transforms.js";
const WORKSPACE_PATH = ".obsidian/workspace.json";
const WORKSPACES_PATH = ".obsidian/workspaces.json";
export function rewriteWorkspacePath(normalizedPath) {
const name = window.__workspaceName;
// Redirect workspace.json to a per-name file when a workspace is active in this tab.
registerPathResolver(
(path) => path === WORKSPACE_PATH && !!window.__workspaceName,
() => `.obsidian/workspace.${window.__workspaceName}.json`,
);
if (!name) {
return normalizedPath;
}
if (normalizedPath === WORKSPACE_PATH) {
return `.obsidian/workspace.${name}.json`;
}
return normalizedPath;
}
export function rewriteWorkspacesContent(content) {
// Keep workspaces.json's active field at the canonical value on disk so other tabs see a stable state.
registerWriteTransform(WORKSPACES_PATH, (content) => {
const original = window.__originalActiveWorkspace;
if (!original || !window.__workspaceName) {
return content;
}
if (typeof content !== "string") {
return content;
}
try {
const parsed = JSON.parse(content);
@@ -35,7 +36,7 @@ export function rewriteWorkspacesContent(content) {
} catch {}
return content;
}
});
function setWorkspaceParam(name) {
const url = new URL(window.location.href);
@@ -136,29 +137,33 @@ export function initWorkspacePatch() {
instance.loadWorkspace = function (name) {
window.__workspaceName = name;
setWorkspaceParam(name);
fsShim._contentCache.invalidate(".obsidian/workspace.json");
fsShim.invalidate(WORKSPACE_PATH);
return origLoad(name);
};
instance.saveWorkspace = function (name) {
// Grab the current layout before switching the transport target.
const currentLayout = fsShim._contentCache.get(".obsidian/workspace.json");
// Grab the current layout before changing __workspaceName.
let currentLayout = null;
try {
currentLayout = fsShim.readFileSync(WORKSPACE_PATH, "utf-8");
} catch {}
window.__workspaceName = name;
setWorkspaceParam(name);
fsShim._contentCache.invalidate(".obsidian/workspace.json");
fsShim.invalidate(WORKSPACE_PATH);
const result = origSave(name);
// Write the layout to the new workspace file so it exists on disk immediately.
if (currentLayout) {
fsShim.writeFileSync(".obsidian/workspace.json", currentLayout, "utf-8");
fsShim.writeFileSync(WORKSPACE_PATH, currentLayout, "utf-8");
}
return result;
};
// Override the active field on reads so the menu matches this tab's workspace.
registerReadTransform(".obsidian/workspaces.json", (data) => {
registerReadTransform(WORKSPACES_PATH, (data) => {
if (!window.__workspaceName) {
return data;
}