mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
Compare commits
8 Commits
v0.8.6+obs
...
v0.8.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aa2b2bd45 | ||
|
|
b6c538fb33 | ||
|
|
201607dbea | ||
|
|
85956dbb3f | ||
|
|
97bcf4fde5 | ||
|
|
9619703a58 | ||
|
|
c60322a287 | ||
|
|
448c6eea2c |
@@ -65,25 +65,67 @@
|
||||
}, 250);
|
||||
}
|
||||
|
||||
update();
|
||||
function appendScripts() {
|
||||
// No Obsidian scripts to load (markup or scrape mismatch); clear the splash instead of pulsing forever.
|
||||
if (scripts.length === 0) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < scripts.length; i++) {
|
||||
var s = document.createElement("script");
|
||||
s.type = "text/javascript";
|
||||
s.src = scripts[i];
|
||||
s.async = false;
|
||||
s.onload = function () {
|
||||
loaded++;
|
||||
update();
|
||||
if (loaded === scripts.length) done();
|
||||
};
|
||||
s.onerror = function () {
|
||||
loaded++;
|
||||
update();
|
||||
if (loaded === scripts.length) done();
|
||||
};
|
||||
document.body.appendChild(s);
|
||||
update();
|
||||
|
||||
for (var i = 0; i < scripts.length; i++) {
|
||||
var s = document.createElement("script");
|
||||
s.type = "text/javascript";
|
||||
s.src = scripts[i];
|
||||
s.async = false;
|
||||
s.onload = function () {
|
||||
loaded++;
|
||||
update();
|
||||
if (loaded === scripts.length) done();
|
||||
};
|
||||
s.onerror = function () {
|
||||
loaded++;
|
||||
update();
|
||||
if (loaded === scripts.length) done();
|
||||
};
|
||||
document.body.appendChild(s);
|
||||
}
|
||||
}
|
||||
|
||||
// Hold Obsidian's scripts until the shim signals the priority cache slice has landed (window.__ignisBootReady), so Obsidian's early config and plugin reads hit the warm cache.
|
||||
// A timeout proceeds anyway, so a missing or never-resolving promise degrades to loading immediately instead of blocking boot.
|
||||
var ready = window.__ignisBootReady;
|
||||
if (!ready || typeof ready.then !== "function") {
|
||||
appendScripts();
|
||||
return;
|
||||
}
|
||||
|
||||
var started = false;
|
||||
|
||||
function start() {
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
|
||||
started = true;
|
||||
// Tell the shim's progress writer to stop touching the splash label now that we own it.
|
||||
window.__ignisBootStarted = true;
|
||||
appendScripts();
|
||||
}
|
||||
|
||||
var timer = setTimeout(start, 3000);
|
||||
|
||||
ready.then(
|
||||
function () {
|
||||
clearTimeout(timer);
|
||||
start();
|
||||
},
|
||||
function () {
|
||||
clearTimeout(timer);
|
||||
start();
|
||||
},
|
||||
);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -31,9 +31,19 @@ async function writeToDisk(absPath, data, encoding) {
|
||||
);
|
||||
|
||||
lastWriteTime.set(absPath, Date.now());
|
||||
const stat = await fs.promises.stat(absPath);
|
||||
|
||||
return { mtime: stat.mtimeMs, size: stat.size };
|
||||
// A concurrent delete can remove the file between the write and the stat (a rapid write-then-delete on the same path).
|
||||
// The write itself succeeds, so report synthetic metadata rather than failing the request on the now-missing file.
|
||||
try {
|
||||
const stat = await fs.promises.stat(absPath);
|
||||
return { mtime: stat.mtimeMs, size: stat.size };
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") {
|
||||
return { mtime: Date.now(), size: estimateSize(data, encoding) };
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function flushEntry(absPath) {
|
||||
|
||||
@@ -99,6 +99,18 @@ describe("writeCoalesced", () => {
|
||||
|
||||
expect(elapsed).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("returns synthetic metadata when the file is deleted before the post-write stat", async () => {
|
||||
const filePath = path.join(tmpDir, "race.txt");
|
||||
vi.spyOn(fs.promises, "stat").mockRejectedValueOnce(
|
||||
Object.assign(new Error("ENOENT"), { code: "ENOENT" }),
|
||||
);
|
||||
|
||||
const result = await coalescer.writeCoalesced(filePath, "hello", "utf-8");
|
||||
|
||||
expect(result.size).toBe(5);
|
||||
expect(result.mtime).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPending", () => {
|
||||
|
||||
@@ -7,6 +7,22 @@ function toOriginSet(list) {
|
||||
return Array.isArray(list) && list.length > 0 ? new Set(list) : null;
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30000;
|
||||
|
||||
// Terminates sockets that have not ponged since the previous sweep, and pings the rest.
|
||||
// A socket silently dropped by an idle-timeout proxy fails the next isAlive check and is terminated.
|
||||
function heartbeatSweep(clients) {
|
||||
for (const ws of clients) {
|
||||
if (ws.isAlive === false) {
|
||||
ws.terminate();
|
||||
continue;
|
||||
}
|
||||
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
}
|
||||
}
|
||||
|
||||
function setupWebSocket(server, opts = {}) {
|
||||
const { getVaultPath, originAllowlist } = opts;
|
||||
|
||||
@@ -126,6 +142,12 @@ function setupWebSocket(server, opts = {}) {
|
||||
const vaultPath = getVaultPath(vaultId);
|
||||
console.log(`[ws] Client connected to vault: ${vaultId}`);
|
||||
|
||||
// isAlive is reset by each pong; the heartbeat sweep terminates sockets that miss one.
|
||||
ws.isAlive = true;
|
||||
ws.on("pong", () => {
|
||||
ws.isAlive = true;
|
||||
});
|
||||
|
||||
if (!clientsByVault.has(vaultId)) {
|
||||
clientsByVault.set(vaultId, new Set());
|
||||
}
|
||||
@@ -209,7 +231,16 @@ function setupWebSocket(server, opts = {}) {
|
||||
});
|
||||
});
|
||||
|
||||
// Terminate dead connections behind proxies that silently drop idle sockets.
|
||||
const heartbeat = setInterval(
|
||||
() => heartbeatSweep(wss.clients),
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
heartbeat.unref?.();
|
||||
|
||||
wss.on("close", () => clearInterval(heartbeat));
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
module.exports = { setupWebSocket };
|
||||
module.exports = { setupWebSocket, heartbeatSweep };
|
||||
|
||||
43
packages/server-core/src/ws.test.mjs
Normal file
43
packages/server-core/src/ws.test.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { heartbeatSweep } = require("./ws.js");
|
||||
|
||||
function fakeSocket(isAlive) {
|
||||
return { isAlive, ping: vi.fn(), terminate: vi.fn() };
|
||||
}
|
||||
|
||||
describe("ws heartbeat sweep", () => {
|
||||
it("terminates a socket that has not ponged since the last sweep", () => {
|
||||
const dead = fakeSocket(false);
|
||||
|
||||
heartbeatSweep([dead]);
|
||||
|
||||
expect(dead.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(dead.ping).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pings a live socket and marks it pending until its next pong", () => {
|
||||
const alive = fakeSocket(true);
|
||||
|
||||
heartbeatSweep([alive]);
|
||||
|
||||
expect(alive.ping).toHaveBeenCalledTimes(1);
|
||||
expect(alive.terminate).not.toHaveBeenCalled();
|
||||
expect(alive.isAlive).toBe(false);
|
||||
});
|
||||
|
||||
it("terminates the dead and pings the live in the same sweep", () => {
|
||||
const dead = fakeSocket(false);
|
||||
const alive = fakeSocket(true);
|
||||
|
||||
heartbeatSweep(new Set([dead, alive]));
|
||||
|
||||
expect(dead.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(dead.ping).not.toHaveBeenCalled();
|
||||
expect(alive.ping).toHaveBeenCalledTimes(1);
|
||||
expect(alive.terminate).not.toHaveBeenCalled();
|
||||
expect(alive.isAlive).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,13 @@ const contentCache = new ContentCache();
|
||||
const fsPromises = createFsPromises(metadataCache, contentCache, transport);
|
||||
const fsSync = createFsSync(metadataCache, contentCache, transport);
|
||||
const fsWatch = createFsWatch(transport);
|
||||
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient);
|
||||
const watcherClient = createWatcherClient(
|
||||
metadataCache,
|
||||
contentCache,
|
||||
fsWatch,
|
||||
wsClient,
|
||||
transport,
|
||||
);
|
||||
const fdOps = createFdOps(metadataCache, contentCache, transport);
|
||||
const fsCallbacks = createFsCallbacks(fsPromises);
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// Eager batch pre-fetch of vault content into ContentCache.
|
||||
//
|
||||
// Fired once after the metadata cache is populated. Iterates the tree in
|
||||
// directory-traversal order and pulls text file contents in batches via
|
||||
// /api/fs/batch-read. Caps at MAX_BYTES so it doesn't thrash the LRU.
|
||||
// Drops content directly into ContentCache; the indexer hits the cache
|
||||
// instead of fetching each file individually.
|
||||
// Batch pre-fetch of vault content into ContentCache.
|
||||
// Pulls text file contents in batches via /api/fs/batch-read and drops them into ContentCache so Obsidian's startup reads hit the cache instead of fetching each file individually.
|
||||
// The priority slice (.obsidian configs and plugin entry files) is fetched first and its promise resolves once it lands, so boot can wait for those reads to be warm.
|
||||
// The bulk slice (everything else) streams afterward without blocking boot.
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
".md", ".markdown", ".txt", ".json", ".csv",
|
||||
@@ -13,8 +10,12 @@ const TEXT_EXTENSIONS = new Set([
|
||||
".svg",
|
||||
]);
|
||||
|
||||
const MAX_BYTES = 30 * 1024 * 1024; // 30 MB
|
||||
const MAX_FILE_BYTES = 512 * 1024; // skip files larger than 512 KB
|
||||
const MAX_BYTES = 30 * 1024 * 1024; // 30 MB total across both slices
|
||||
const MAX_FILE_BYTES = 512 * 1024; // skip bulk files larger than 512 KB
|
||||
// Plugin main.js bundles can run a few MB and Obsidian needs them at boot, so the priority slice accepts larger files than the bulk slice.
|
||||
const PRIORITY_MAX_FILE_BYTES = 4 * 1024 * 1024; // 4 MB
|
||||
// Cap the priority slice's share of the total so a heavy config or plugin set cannot starve the bulk slice.
|
||||
const PRIORITY_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
function isTextPath(path) {
|
||||
@@ -27,36 +28,68 @@ function isTextPath(path) {
|
||||
return TEXT_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
||||
}
|
||||
|
||||
function selectPrefetchTargets(tree) {
|
||||
const paths = [];
|
||||
// Boot-critical files: root-level .obsidian configs and each plugin's entry files.
|
||||
// Plugin data.json and other nested config fall to the bulk slice so a large blob does not inflate the awaited slice.
|
||||
function isPriorityPath(path) {
|
||||
if (!path.startsWith(".obsidian/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Root-level configs only (app.json, appearance.json, core-plugins.json, workspace.json, etc.).
|
||||
if (/^\.obsidian\/[^/]+\.json$/.test(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^\.obsidian\/plugins\/[^/]+\/(main\.js|manifest\.json|styles\.css)$/.test(
|
||||
path,
|
||||
);
|
||||
}
|
||||
|
||||
function collectSlice(entries, predicate, perFileCap, budget) {
|
||||
const files = [];
|
||||
let bytes = 0;
|
||||
|
||||
// Iterate in tree key order, which already matches directory traversal
|
||||
// because the server's walk emits parent-before-children.
|
||||
for (const [path, entry] of Object.entries(tree)) {
|
||||
if (entry.type !== "file") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isTextPath(path)) {
|
||||
for (const [path, entry] of entries) {
|
||||
if (entry.type !== "file" || !isTextPath(path) || !predicate(path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const size = entry.size || 0;
|
||||
|
||||
if (size === 0 || size > MAX_FILE_BYTES) {
|
||||
if (size === 0 || size > perFileCap) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bytes + size > MAX_BYTES) {
|
||||
break;
|
||||
if (bytes + size > budget) {
|
||||
continue;
|
||||
}
|
||||
|
||||
paths.push(path);
|
||||
files.push({ path, size });
|
||||
bytes += size;
|
||||
}
|
||||
|
||||
return { paths, bytes };
|
||||
return { files, bytes };
|
||||
}
|
||||
|
||||
function selectPrefetchTargets(tree) {
|
||||
// Tree key order matches directory traversal (the server walk emits parent before children).
|
||||
const entries = Object.entries(tree);
|
||||
const priority = collectSlice(
|
||||
entries,
|
||||
isPriorityPath,
|
||||
PRIORITY_MAX_FILE_BYTES,
|
||||
PRIORITY_MAX_BYTES,
|
||||
);
|
||||
|
||||
// Bulk fills whatever byte budget the priority slice left.
|
||||
const bulk = collectSlice(
|
||||
entries,
|
||||
(path) => !isPriorityPath(path),
|
||||
MAX_FILE_BYTES,
|
||||
MAX_BYTES - priority.bytes,
|
||||
);
|
||||
|
||||
return { priority, bulk };
|
||||
}
|
||||
|
||||
async function fetchBatch(vaultId, paths) {
|
||||
@@ -73,41 +106,84 @@ async function fetchBatch(vaultId, paths) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function prefetchVaultContent(vaultId, tree, contentCache) {
|
||||
if (!vaultId || !tree) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { paths, bytes } = selectPrefetchTargets(tree);
|
||||
|
||||
if (paths.length === 0) {
|
||||
async function runBatches(vaultId, slice, contentCache, label, onProgress) {
|
||||
if (slice.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
let cached = 0;
|
||||
let received = 0;
|
||||
|
||||
for (let i = 0; i < paths.length; i += BATCH_SIZE) {
|
||||
const batch = paths.slice(i, i + BATCH_SIZE);
|
||||
// Report the total up front so the splash shows the target before the first batch lands.
|
||||
if (onProgress) {
|
||||
onProgress(0, slice.bytes);
|
||||
}
|
||||
|
||||
for (let i = 0; i < slice.files.length; i += BATCH_SIZE) {
|
||||
const batch = slice.files.slice(i, i + BATCH_SIZE);
|
||||
|
||||
let result;
|
||||
|
||||
try {
|
||||
const result = await fetchBatch(vaultId, batch);
|
||||
|
||||
for (const [path, content] of Object.entries(result.files || {})) {
|
||||
if (typeof content === "string") {
|
||||
contentCache.set(path, content);
|
||||
cached++;
|
||||
}
|
||||
}
|
||||
result = await fetchBatch(
|
||||
vaultId,
|
||||
batch.map((f) => f.path),
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("[ignis] Prefetch batch failed:", e.message);
|
||||
// Abandon the rest of this slice; the returned promise still resolves so boot is never blocked on a failed batch.
|
||||
console.warn(`[ignis] Prefetch ${label} batch failed:`, e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [path, content] of Object.entries(result.files || {})) {
|
||||
if (typeof content === "string") {
|
||||
contentCache.set(path, content);
|
||||
cached++;
|
||||
}
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
for (const f of batch) {
|
||||
received += f.size;
|
||||
}
|
||||
|
||||
onProgress(received, slice.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
const ms = Date.now() - t0;
|
||||
|
||||
console.log(
|
||||
`[ignis] Prefetched ${cached}/${paths.length} files (${(bytes / 1024).toFixed(0)} KB) in ${ms}ms`,
|
||||
`[ignis] Prefetched ${label} ${cached}/${slice.files.length} files (${(slice.bytes / 1024).toFixed(0)} KB) in ${ms}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
// Returns { priority, bulk }: a promise for each slice.
|
||||
// The priority promise resolves once the boot-critical files have landed (or were abandoned on a batch failure), so it is always safe to await.
|
||||
export function prefetchVaultContent(vaultId, tree, contentCache, options = {}) {
|
||||
if (!vaultId || !tree) {
|
||||
return { priority: Promise.resolve(), bulk: Promise.resolve() };
|
||||
}
|
||||
|
||||
const { priority, bulk } = selectPrefetchTargets(tree);
|
||||
|
||||
const priorityDone = runBatches(
|
||||
vaultId,
|
||||
priority,
|
||||
contentCache,
|
||||
"priority",
|
||||
options.onProgress,
|
||||
);
|
||||
|
||||
// Bulk streams after the priority slice so it does not contend for the connection pool while boot is waiting on priority.
|
||||
// It runs regardless of how priority settled and swallows its own rejection, since init.js discards this promise.
|
||||
const bulkDone = priorityDone
|
||||
.catch(() => {})
|
||||
.then(() => runBatches(vaultId, bulk, contentCache, "bulk"))
|
||||
.catch((e) => {
|
||||
console.warn("[ignis] Prefetch bulk failed:", e && e.message);
|
||||
});
|
||||
|
||||
return { priority: priorityDone, bulk: bulkDone };
|
||||
}
|
||||
|
||||
147
packages/shim/src/fs/indexer-prefetch.test.js
Normal file
147
packages/shim/src/fs/indexer-prefetch.test.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { prefetchVaultContent } from "./indexer-prefetch.js";
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
// Every selection rule.
|
||||
const tree = {
|
||||
".obsidian/app.json": { type: "file", size: 100 },
|
||||
".obsidian/community-plugins.json": { type: "file", size: 50 },
|
||||
".obsidian/plugins/big/main.js": { type: "file", size: 2 * MB },
|
||||
".obsidian/plugins/big/manifest.json": { type: "file", size: 80 },
|
||||
".obsidian/plugins/big/styles.css": { type: "file", size: 200 },
|
||||
".obsidian/plugins/big/data.json": { type: "file", size: 300 * 1024 },
|
||||
"Note.md": { type: "file", size: 100 },
|
||||
"Big.md": { type: "file", size: 600 * 1024 },
|
||||
"plugins/fake/main.js": { type: "file", size: 100 },
|
||||
somedir: { type: "directory" },
|
||||
};
|
||||
|
||||
const PRIORITY_BYTES = 100 + 50 + 2 * MB + 80 + 200;
|
||||
|
||||
let fetchCalls;
|
||||
|
||||
function makeCache() {
|
||||
const store = new Map();
|
||||
return { store, set: (path, content) => store.set(path, content) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCalls = [];
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
const paths = JSON.parse(init.body).paths;
|
||||
fetchCalls.push(paths);
|
||||
|
||||
const files = {};
|
||||
|
||||
for (const p of paths) {
|
||||
files[p] = "content:" + p;
|
||||
}
|
||||
|
||||
return { ok: true, json: async () => ({ files }) };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.fetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("prefetchVaultContent slicing", () => {
|
||||
it("fetches the priority slice before the bulk slice", async () => {
|
||||
const result = prefetchVaultContent("v", tree, makeCache());
|
||||
await result.bulk;
|
||||
|
||||
expect(fetchCalls.length).toBe(2);
|
||||
const [priorityPaths, bulkPaths] = fetchCalls;
|
||||
|
||||
expect(priorityPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
".obsidian/app.json",
|
||||
".obsidian/community-plugins.json",
|
||||
".obsidian/plugins/big/main.js",
|
||||
".obsidian/plugins/big/manifest.json",
|
||||
".obsidian/plugins/big/styles.css",
|
||||
]),
|
||||
);
|
||||
expect(priorityPaths).not.toContain("Note.md");
|
||||
|
||||
expect(bulkPaths).toEqual(
|
||||
expect.arrayContaining(["Note.md", "plugins/fake/main.js"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("anchors the plugin predicate to .obsidian, so a bare plugins/ path is bulk", async () => {
|
||||
const result = prefetchVaultContent("v", tree, makeCache());
|
||||
await result.bulk;
|
||||
|
||||
expect(fetchCalls[0]).not.toContain("plugins/fake/main.js");
|
||||
expect(fetchCalls[1]).toContain("plugins/fake/main.js");
|
||||
});
|
||||
|
||||
it("leaves plugin data.json to the bulk slice, not priority", async () => {
|
||||
const result = prefetchVaultContent("v", tree, makeCache());
|
||||
await result.bulk;
|
||||
|
||||
expect(fetchCalls[0]).not.toContain(".obsidian/plugins/big/data.json");
|
||||
expect(fetchCalls[1]).toContain(".obsidian/plugins/big/data.json");
|
||||
});
|
||||
|
||||
it("caps the priority slice at its own byte budget", async () => {
|
||||
// Three 4MB plugin entry files: two fit the 10MB priority budget, the third is dropped.
|
||||
const bigTree = {
|
||||
".obsidian/plugins/a/main.js": { type: "file", size: 4 * MB },
|
||||
".obsidian/plugins/b/main.js": { type: "file", size: 4 * MB },
|
||||
".obsidian/plugins/c/main.js": { type: "file", size: 4 * MB },
|
||||
};
|
||||
|
||||
const result = prefetchVaultContent("v", bigTree, makeCache());
|
||||
await result.bulk;
|
||||
|
||||
expect(fetchCalls[0].length).toBe(2);
|
||||
});
|
||||
|
||||
it("drops a bulk file over the 512KB per-file cap", async () => {
|
||||
const result = prefetchVaultContent("v", tree, makeCache());
|
||||
await result.bulk;
|
||||
|
||||
expect(fetchCalls.flat()).not.toContain("Big.md");
|
||||
});
|
||||
|
||||
it("reports priority byte progress from zero up to the slice total", async () => {
|
||||
const onProgress = vi.fn();
|
||||
const result = prefetchVaultContent("v", tree, makeCache(), { onProgress });
|
||||
await result.priority;
|
||||
|
||||
expect(onProgress).toHaveBeenCalledWith(0, PRIORITY_BYTES);
|
||||
expect(onProgress).toHaveBeenLastCalledWith(PRIORITY_BYTES, PRIORITY_BYTES);
|
||||
});
|
||||
|
||||
it("caches returned content under its path", async () => {
|
||||
const cache = makeCache();
|
||||
const result = prefetchVaultContent("v", tree, cache);
|
||||
await result.bulk;
|
||||
|
||||
expect(cache.store.get(".obsidian/app.json")).toBe(
|
||||
"content:.obsidian/app.json",
|
||||
);
|
||||
expect(cache.store.get("Note.md")).toBe("content:Note.md");
|
||||
});
|
||||
|
||||
it("resolves both promises without fetching when there is no vault", async () => {
|
||||
const result = prefetchVaultContent("", tree, makeCache());
|
||||
|
||||
await result.priority;
|
||||
await result.bulk;
|
||||
|
||||
expect(fetchCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("resolves the priority promise even when a batch fails", async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({ ok: false, status: 500 }));
|
||||
|
||||
const result = prefetchVaultContent("v", tree, makeCache());
|
||||
|
||||
await expect(result.priority).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,11 @@ export class MetadataCache {
|
||||
return this._entries.size;
|
||||
}
|
||||
|
||||
// Normalized keys of every entry, for callers that diff the cache against a fresh tree.
|
||||
keys() {
|
||||
return [...this._entries.keys()];
|
||||
}
|
||||
|
||||
toStat(path) {
|
||||
const meta = this.get(path);
|
||||
|
||||
|
||||
140
packages/shim/src/fs/promises-mutations.test.js
Normal file
140
packages/shim/src/fs/promises-mutations.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { createFsPromises } from "./promises.js";
|
||||
import { registerPathResolver, _reset } from "./transforms.js";
|
||||
import { isRecentLocalOp } from "./echo-guard.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),
|
||||
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 () => {}),
|
||||
stat: vi.fn(async () => ({ type: "file", size: 1 })),
|
||||
readFile: vi.fn(async () => {
|
||||
throw new Error("transport.readFile should not be called");
|
||||
}),
|
||||
};
|
||||
|
||||
return { metadataCache, contentCache, transport, store };
|
||||
}
|
||||
|
||||
describe("promises directory mutations honor path resolvers", () => {
|
||||
afterEach(() => _reset());
|
||||
|
||||
it("mkdir uses the resolved path for cache, echo-guard, and transport", async () => {
|
||||
registerPathResolver(
|
||||
(p) => p === "logical/dir",
|
||||
() => "physical/dir",
|
||||
);
|
||||
|
||||
const deps = makeDeps();
|
||||
const fs = createFsPromises(
|
||||
deps.metadataCache,
|
||||
deps.contentCache,
|
||||
deps.transport,
|
||||
);
|
||||
|
||||
await fs.mkdir("logical/dir", { recursive: true });
|
||||
|
||||
expect(deps.store.get("physical/dir")).toEqual({ type: "directory" });
|
||||
expect(deps.store.has("logical/dir")).toBe(false);
|
||||
expect(deps.transport.mkdir).toHaveBeenCalledWith("physical/dir", true);
|
||||
expect(isRecentLocalOp("physical/dir")).toBe(true);
|
||||
expect(isRecentLocalOp("logical/dir")).toBe(false);
|
||||
});
|
||||
|
||||
it("rmdir uses the resolved path for cache, echo-guard, and transport", async () => {
|
||||
registerPathResolver(
|
||||
(p) => p === "logical/dir",
|
||||
() => "physical/dir",
|
||||
);
|
||||
|
||||
const deps = makeDeps();
|
||||
const fs = createFsPromises(
|
||||
deps.metadataCache,
|
||||
deps.contentCache,
|
||||
deps.transport,
|
||||
);
|
||||
deps.store.set("physical/dir", { type: "directory" });
|
||||
|
||||
await fs.rmdir("logical/dir");
|
||||
|
||||
expect(deps.store.has("physical/dir")).toBe(false);
|
||||
expect(deps.transport.rmdir).toHaveBeenCalledWith("physical/dir");
|
||||
expect(isRecentLocalOp("physical/dir")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("promises readFile existence", () => {
|
||||
afterEach(() => _reset());
|
||||
|
||||
it("answers ENOENT from the cache for a missing non-redirected path, no transport", async () => {
|
||||
const deps = makeDeps();
|
||||
const fs = createFsPromises(
|
||||
deps.metadataCache,
|
||||
deps.contentCache,
|
||||
deps.transport,
|
||||
);
|
||||
|
||||
await expect(
|
||||
fs.readFile("/.obsidian/backlink.json", "utf8"),
|
||||
).rejects.toThrow(/ENOENT/);
|
||||
expect(deps.transport.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the original path for a redirected miss", async () => {
|
||||
registerPathResolver(
|
||||
(p) => p === ".obsidian/workspace.json",
|
||||
() => ".obsidian/workspace.Work.json",
|
||||
);
|
||||
|
||||
const deps = makeDeps();
|
||||
deps.transport.readFile = vi.fn(async (p) => {
|
||||
if (p === ".obsidian/workspace.Work.json") {
|
||||
const e = new Error("ENOENT");
|
||||
e.code = "ENOENT";
|
||||
throw e;
|
||||
}
|
||||
|
||||
return "BASE";
|
||||
});
|
||||
|
||||
const fs = createFsPromises(
|
||||
deps.metadataCache,
|
||||
deps.contentCache,
|
||||
deps.transport,
|
||||
);
|
||||
|
||||
// Returns the base content after the redirect target 404s: the fallback fired.
|
||||
await expect(fs.readFile("/.obsidian/workspace.json", "utf8")).resolves.toBe(
|
||||
"BASE",
|
||||
);
|
||||
expect(deps.transport.readFile).toHaveBeenCalledWith(
|
||||
".obsidian/workspace.Work.json",
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
applyReadTransform,
|
||||
applyWriteTransform,
|
||||
resolvePath,
|
||||
resolvePathInfo,
|
||||
} from "./transforms.js";
|
||||
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
|
||||
import { realpathSync } from "./realpath.js";
|
||||
@@ -53,7 +54,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
}
|
||||
|
||||
const wantText = encoding === "utf8" || encoding === "utf-8";
|
||||
const resolved = resolvePath(path);
|
||||
const { resolved, redirected } = resolvePathInfo(path);
|
||||
|
||||
// Virtual plugin source overrides any cache/transport version.
|
||||
if (hasVirtualFile(resolved)) {
|
||||
@@ -86,8 +87,9 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (!meta && resolved && resolved === path) {
|
||||
// Throw ENOENT only when not redirected; redirected paths fall through to the transport's fallback.
|
||||
if (!meta && !redirected) {
|
||||
// The metadata cache holds every existing path (populated at bootstrap, kept current by the watcher).
|
||||
// A cache miss on a non-redirected path is genuinely absent. Redirected paths fall through to the transport.
|
||||
const e = new Error(
|
||||
`ENOENT: no such file or directory, open '${path}'`,
|
||||
);
|
||||
@@ -102,7 +104,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
try {
|
||||
result = await transport.readFile(resolved, encoding);
|
||||
} catch (e) {
|
||||
if (resolved !== path && e.code === "ENOENT") {
|
||||
if (redirected && e.code === "ENOENT") {
|
||||
result = await transport.readFile(path, encoding);
|
||||
} else {
|
||||
throw e;
|
||||
@@ -207,16 +209,20 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
const recursive =
|
||||
typeof options === "object" ? !!options.recursive : !!options;
|
||||
|
||||
markLocalOp(path);
|
||||
metadataCache.set(path, { type: "directory" });
|
||||
const resolved = resolvePath(path);
|
||||
|
||||
await transport.mkdir(path, recursive);
|
||||
markLocalOp(resolved);
|
||||
metadataCache.set(resolved, { type: "directory" });
|
||||
|
||||
await transport.mkdir(resolved, recursive);
|
||||
},
|
||||
|
||||
async rmdir(path) {
|
||||
markLocalOp(path);
|
||||
metadataCache.delete(path);
|
||||
await transport.rmdir(path);
|
||||
const resolved = resolvePath(path);
|
||||
|
||||
markLocalOp(resolved);
|
||||
metadataCache.delete(resolved);
|
||||
await transport.rmdir(resolved);
|
||||
},
|
||||
|
||||
async rm(path, options) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { createFsSync } from "./sync.js";
|
||||
import { resolvePath } from "./transforms.js";
|
||||
import { resolvePath, registerPathResolver, _reset } from "./transforms.js";
|
||||
import { isRecentLocalOp } from "./echo-guard.js";
|
||||
|
||||
function makeDeps() {
|
||||
const store = new Map();
|
||||
@@ -43,6 +44,9 @@ function makeDeps() {
|
||||
appendFile: vi.fn(async () => {}),
|
||||
utimes: vi.fn(async () => {}),
|
||||
stat: vi.fn(async () => ({ type: "file", size: 1 })),
|
||||
readFileSync: vi.fn(() => {
|
||||
throw new Error("transport.readFileSync should not be called");
|
||||
}),
|
||||
};
|
||||
|
||||
return { metadataCache, contentCache, transport, store };
|
||||
@@ -51,7 +55,11 @@ function makeDeps() {
|
||||
describe("sync fs mutations", () => {
|
||||
it("lstatSync mirrors statSync", () => {
|
||||
const deps = makeDeps();
|
||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||
const fs = createFsSync(
|
||||
deps.metadataCache,
|
||||
deps.contentCache,
|
||||
deps.transport,
|
||||
);
|
||||
deps.store.set(resolvePath("dir"), { type: "directory" });
|
||||
|
||||
expect(fs.lstatSync("dir").isDirectory()).toBe(true);
|
||||
@@ -59,7 +67,11 @@ describe("sync fs mutations", () => {
|
||||
|
||||
it("mkdirSync updates the cache and fires the transport", () => {
|
||||
const deps = makeDeps();
|
||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||
const fs = createFsSync(
|
||||
deps.metadataCache,
|
||||
deps.contentCache,
|
||||
deps.transport,
|
||||
);
|
||||
|
||||
fs.mkdirSync("newdir", { recursive: true });
|
||||
|
||||
@@ -69,7 +81,11 @@ describe("sync fs mutations", () => {
|
||||
|
||||
it("rmSync deletes from the cache and fires the transport", () => {
|
||||
const deps = makeDeps();
|
||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||
const fs = createFsSync(
|
||||
deps.metadataCache,
|
||||
deps.contentCache,
|
||||
deps.transport,
|
||||
);
|
||||
const key = resolvePath("gone.md");
|
||||
deps.store.set(key, { type: "file" });
|
||||
|
||||
@@ -81,7 +97,11 @@ describe("sync fs mutations", () => {
|
||||
|
||||
it("renameSync moves cache metadata and fires the transport", () => {
|
||||
const deps = makeDeps();
|
||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||
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 });
|
||||
@@ -95,7 +115,11 @@ describe("sync fs mutations", () => {
|
||||
|
||||
it("copyFileSync optimistically mirrors source metadata and fires the transport", () => {
|
||||
const deps = makeDeps();
|
||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||
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 });
|
||||
@@ -108,7 +132,11 @@ describe("sync fs mutations", () => {
|
||||
|
||||
it("utimesSync sets mtime and fires the transport", () => {
|
||||
const deps = makeDeps();
|
||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||
const fs = createFsSync(
|
||||
deps.metadataCache,
|
||||
deps.contentCache,
|
||||
deps.transport,
|
||||
);
|
||||
const key = resolvePath("note.md");
|
||||
deps.store.set(key, { type: "file", mtime: 0 });
|
||||
|
||||
@@ -117,12 +145,101 @@ describe("sync fs mutations", () => {
|
||||
expect(deps.store.get(key).mtime).toBe(222);
|
||||
expect(deps.transport.utimes).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory mutations honor path resolvers", () => {
|
||||
afterEach(() => _reset());
|
||||
|
||||
it("mkdirSync uses the resolved path for cache, echo-guard, and transport", () => {
|
||||
registerPathResolver(
|
||||
(p) => p === "logical/dir",
|
||||
() => "physical/dir",
|
||||
);
|
||||
|
||||
it("chmodSync is a no-op that does not throw", () => {
|
||||
const deps = makeDeps();
|
||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||
const fs = createFsSync(
|
||||
deps.metadataCache,
|
||||
deps.contentCache,
|
||||
deps.transport,
|
||||
);
|
||||
|
||||
expect(() => fs.chmodSync("note.md", 0o644)).not.toThrow();
|
||||
expect(fs.chmodSync("note.md", 0o644)).toBeUndefined();
|
||||
fs.mkdirSync("logical/dir", { recursive: true });
|
||||
|
||||
expect(deps.store.get("physical/dir")).toEqual({ type: "directory" });
|
||||
expect(deps.store.has("logical/dir")).toBe(false);
|
||||
expect(deps.transport.mkdir).toHaveBeenCalledWith("physical/dir", true);
|
||||
expect(isRecentLocalOp("physical/dir")).toBe(true);
|
||||
expect(isRecentLocalOp("logical/dir")).toBe(false);
|
||||
});
|
||||
|
||||
it("rmdirSync uses the resolved path for cache, echo-guard, and transport", () => {
|
||||
registerPathResolver(
|
||||
(p) => p === "logical/dir",
|
||||
() => "physical/dir",
|
||||
);
|
||||
|
||||
const deps = makeDeps();
|
||||
const fs = createFsSync(
|
||||
deps.metadataCache,
|
||||
deps.contentCache,
|
||||
deps.transport,
|
||||
);
|
||||
deps.store.set("physical/dir", { type: "directory" });
|
||||
|
||||
fs.rmdirSync("logical/dir");
|
||||
|
||||
expect(deps.store.has("physical/dir")).toBe(false);
|
||||
expect(deps.transport.rmdir).toHaveBeenCalledWith("physical/dir");
|
||||
expect(isRecentLocalOp("physical/dir")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readFileSync existence", () => {
|
||||
afterEach(() => _reset());
|
||||
|
||||
it("answers ENOENT from the cache for a missing non-redirected path, no transport", () => {
|
||||
const deps = makeDeps();
|
||||
const fs = createFsSync(
|
||||
deps.metadataCache,
|
||||
deps.contentCache,
|
||||
deps.transport,
|
||||
);
|
||||
|
||||
// Leading slash: normalize strips it, so resolved !== the raw argument.
|
||||
expect(() => fs.readFileSync("/.obsidian/backlink.json", "utf8")).toThrow(
|
||||
/ENOENT/,
|
||||
);
|
||||
expect(deps.transport.readFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the original path for a redirected miss", () => {
|
||||
registerPathResolver(
|
||||
(p) => p === ".obsidian/workspace.json",
|
||||
() => ".obsidian/workspace.Work.json",
|
||||
);
|
||||
|
||||
const deps = makeDeps();
|
||||
deps.transport.readFileSync = vi.fn((p) => {
|
||||
if (p === ".obsidian/workspace.Work.json") {
|
||||
const e = new Error("ENOENT");
|
||||
e.code = "ENOENT";
|
||||
throw e;
|
||||
}
|
||||
|
||||
return "BASE";
|
||||
});
|
||||
|
||||
const fs = createFsSync(
|
||||
deps.metadataCache,
|
||||
deps.contentCache,
|
||||
deps.transport,
|
||||
);
|
||||
|
||||
// Returns the base content after the redirect target 404s: the fallback fired.
|
||||
expect(fs.readFileSync("/.obsidian/workspace.json", "utf8")).toBe("BASE");
|
||||
expect(deps.transport.readFileSync).toHaveBeenCalledWith(
|
||||
".obsidian/workspace.Work.json",
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
applyReadTransform,
|
||||
applyWriteTransform,
|
||||
resolvePath,
|
||||
resolvePathInfo,
|
||||
} from "./transforms.js";
|
||||
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
|
||||
|
||||
@@ -69,7 +70,7 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||
}
|
||||
|
||||
const wantText = encoding === "utf8" || encoding === "utf-8";
|
||||
const resolved = resolvePath(path);
|
||||
const { resolved, redirected } = resolvePathInfo(path);
|
||||
|
||||
// Virtual plugin source overrides any cache or transport version.
|
||||
if (hasVirtualFile(resolved)) {
|
||||
@@ -108,13 +109,22 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||
result = contentCache.get(resolved);
|
||||
}
|
||||
|
||||
// The metadata cache is kept fresh by the filewatcher and a miss here genuinely means the file is absent.
|
||||
// Redirected paths fall through to the transport, so we can't trust the cache for them, but non-redirected misses are definitive.
|
||||
if (result === null && !meta && !redirected) {
|
||||
const e = new Error(
|
||||
`ENOENT: no such file or directory, open '${path}'`,
|
||||
);
|
||||
e.code = "ENOENT";
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
// ENOENT fallback: if the resolved path doesn't exist, try the original.
|
||||
// Covers per-name workspace files that haven't been saved yet.
|
||||
// A resolver can map a path onto a physical target that does not exist yet, so a redirected miss retries the original path before failing.
|
||||
try {
|
||||
result = transport.readFileSync(resolved, encoding);
|
||||
} catch (e) {
|
||||
if (resolved !== path && e.code === "ENOENT") {
|
||||
if (redirected && e.code === "ENOENT") {
|
||||
console.warn(
|
||||
"[shim:fs] readFileSync cache miss, using sync XHR:",
|
||||
path,
|
||||
@@ -206,20 +216,32 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||
const recursive =
|
||||
typeof options === "object" ? !!options.recursive : !!options;
|
||||
|
||||
markLocalOp(path);
|
||||
metadataCache.set(path, { type: "directory" });
|
||||
const resolved = resolvePath(path);
|
||||
|
||||
transport.mkdir(path, recursive).catch((e) => {
|
||||
console.error("[shim:fs] mkdirSync background create failed:", path, e);
|
||||
markLocalOp(resolved);
|
||||
metadataCache.set(resolved, { type: "directory" });
|
||||
|
||||
transport.mkdir(resolved, recursive).catch((e) => {
|
||||
console.error(
|
||||
"[shim:fs] mkdirSync background create failed:",
|
||||
resolved,
|
||||
e,
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
rmdirSync(path) {
|
||||
markLocalOp(path);
|
||||
metadataCache.delete(path);
|
||||
const resolved = resolvePath(path);
|
||||
|
||||
transport.rmdir(path).catch((e) => {
|
||||
console.error("[shim:fs] rmdirSync background remove failed:", path, e);
|
||||
markLocalOp(resolved);
|
||||
metadataCache.delete(resolved);
|
||||
|
||||
transport.rmdir(resolved).catch((e) => {
|
||||
console.error(
|
||||
"[shim:fs] rmdirSync background remove failed:",
|
||||
resolved,
|
||||
e,
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ export function registerPathResolver(matcher, resolver) {
|
||||
pathResolvers.push({ matcher, resolver });
|
||||
}
|
||||
|
||||
export function resolvePath(path) {
|
||||
// resolved is the physical path.
|
||||
// redirected is true when a path resolver sent the request to a different path.
|
||||
export function resolvePathInfo(path) {
|
||||
const norm = normalize(path);
|
||||
|
||||
for (const { matcher, resolver } of pathResolvers) {
|
||||
@@ -21,13 +23,17 @@ export function resolvePath(path) {
|
||||
const resolved = resolver(norm);
|
||||
|
||||
if (typeof resolved === "string" && resolved.length > 0) {
|
||||
return resolved;
|
||||
return { resolved, redirected: true };
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return norm;
|
||||
return { resolved: norm, redirected: false };
|
||||
}
|
||||
|
||||
export function resolvePath(path) {
|
||||
return resolvePathInfo(path).resolved;
|
||||
}
|
||||
|
||||
// --- Read transforms ---
|
||||
|
||||
@@ -19,6 +19,18 @@ function vaultId() {
|
||||
return window.__currentVaultId || "";
|
||||
}
|
||||
|
||||
const KEEPALIVE_MAX_BYTES = 64 * 1024;
|
||||
|
||||
// keepalive lets a request finish after the page starts unloading.
|
||||
// Its body is capped at 64KB across a shared pool, so opt in only under that limit.
|
||||
function withinKeepaliveCap(body) {
|
||||
if (!body) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new TextEncoder().encode(body).length <= KEEPALIVE_MAX_BYTES;
|
||||
}
|
||||
|
||||
async function request(method, endpoint, params = {}) {
|
||||
const url = new URL(API_BASE + endpoint, window.location.origin);
|
||||
|
||||
@@ -37,6 +49,11 @@ async function request(method, endpoint, params = {}) {
|
||||
options.body = JSON.stringify({ vault: vaultId(), ...params });
|
||||
}
|
||||
|
||||
// A write (POST/DELETE) opts into keepalive so a page dismissal does not drop it.
|
||||
if (method !== "GET" && withinKeepaliveCap(options.body)) {
|
||||
options.keepalive = true;
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), options);
|
||||
if (!res.ok) {
|
||||
const err = await res
|
||||
|
||||
60
packages/shim/src/fs/transport.test.js
Normal file
60
packages/shim/src/fs/transport.test.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { transport } from "./transport.js";
|
||||
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
text: async () => "",
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
}));
|
||||
globalThis.fetch = fetchMock;
|
||||
globalThis.window = {
|
||||
location: { origin: "http://localhost" },
|
||||
__currentVaultId: "v",
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.fetch;
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
function lastInit() {
|
||||
return fetchMock.mock.calls.at(-1)[1];
|
||||
}
|
||||
|
||||
describe("transport keepalive gating", () => {
|
||||
it("sets keepalive on a small write", async () => {
|
||||
await transport.writeFile("a.md", "hello", "utf-8");
|
||||
|
||||
expect(lastInit().keepalive).toBe(true);
|
||||
});
|
||||
|
||||
it("omits keepalive when the body exceeds the 64KB cap", async () => {
|
||||
await transport.writeFile("a.md", "x".repeat(70 * 1024), "utf-8");
|
||||
|
||||
expect(lastInit().keepalive).toBeFalsy();
|
||||
});
|
||||
|
||||
it("counts base64 inflation against the cap for binary writes", async () => {
|
||||
// 60KB of bytes inflates to ~80KB of base64, over the cap.
|
||||
await transport.writeFile("a.bin", new Uint8Array(60 * 1024));
|
||||
|
||||
expect(lastInit().keepalive).toBeFalsy();
|
||||
});
|
||||
|
||||
it("sets keepalive on a bodyless delete", async () => {
|
||||
await transport.unlink("a.md");
|
||||
|
||||
expect(lastInit().keepalive).toBe(true);
|
||||
});
|
||||
|
||||
it("does not set keepalive on a read", async () => {
|
||||
await transport.readFile("a.md", "utf8");
|
||||
|
||||
expect(lastInit().keepalive).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,17 @@
|
||||
// The WebSocket itself is owned by ws-client.js; this module is a consumer.
|
||||
|
||||
import { isRecentLocalOp } from "./echo-guard.js";
|
||||
import { normalize } from "../util/path.js";
|
||||
|
||||
export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClient) {
|
||||
const RESYNC_DEBOUNCE_MS = 1000;
|
||||
|
||||
export function createWatcherClient(
|
||||
metadataCache,
|
||||
contentCache,
|
||||
fsWatch,
|
||||
wsClient,
|
||||
transport,
|
||||
) {
|
||||
function handleCreated(msg) {
|
||||
const { path, stat } = msg;
|
||||
|
||||
@@ -72,6 +81,74 @@ export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClie
|
||||
wsClient.subscribe("modified", handleModified);
|
||||
wsClient.subscribe("deleted", handleDeleted);
|
||||
|
||||
// Re-derive the cache from a freshly fetched tree after a reconnect.
|
||||
// Each delta runs through the live-event handlers, matching live behavior.
|
||||
function reconcile(tree) {
|
||||
const fresh = new Set(Object.keys(tree).map(normalize));
|
||||
|
||||
for (const [path, meta] of Object.entries(tree)) {
|
||||
const existing = metadataCache.get(path);
|
||||
|
||||
if (!existing) {
|
||||
if (meta.type === "directory") {
|
||||
handleFolderCreated({ path });
|
||||
} else {
|
||||
handleCreated({
|
||||
path,
|
||||
stat: { size: meta.size, mtime: meta.mtime, ctime: meta.ctime },
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
meta.type === "file" &&
|
||||
(existing.mtime !== meta.mtime || existing.size !== meta.size)
|
||||
) {
|
||||
handleModified({
|
||||
path,
|
||||
stat: { size: meta.size, mtime: meta.mtime, ctime: meta.ctime },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A cache key absent from the fresh tree was deleted while disconnected.
|
||||
// The empty root key is preserved because the tree never lists it.
|
||||
for (const key of metadataCache.keys()) {
|
||||
if (key === "" || fresh.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
handleDeleted({ path: key });
|
||||
}
|
||||
}
|
||||
|
||||
async function resync() {
|
||||
let tree;
|
||||
|
||||
try {
|
||||
tree = await transport.fetchTree();
|
||||
} catch (e) {
|
||||
console.warn("[shim:fs] reconnect resync failed:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
reconcile(tree);
|
||||
}
|
||||
|
||||
// Coalesce a burst of reconnects into a single resync once the socket settles.
|
||||
let resyncTimer = null;
|
||||
|
||||
function scheduleResync() {
|
||||
if (resyncTimer) {
|
||||
clearTimeout(resyncTimer);
|
||||
}
|
||||
|
||||
resyncTimer = setTimeout(() => {
|
||||
resyncTimer = null;
|
||||
resync();
|
||||
}, RESYNC_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
wsClient.onReconnect(scheduleResync);
|
||||
|
||||
function connect(vaultId) {
|
||||
wsClient.connect(vaultId);
|
||||
}
|
||||
@@ -83,5 +160,6 @@ export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClie
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
reconcile,
|
||||
};
|
||||
}
|
||||
|
||||
101
packages/shim/src/fs/watcher-client.test.js
Normal file
101
packages/shim/src/fs/watcher-client.test.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createWatcherClient } from "./watcher-client.js";
|
||||
import { markLocalOp } from "./echo-guard.js";
|
||||
|
||||
function makeDeps() {
|
||||
const store = new Map();
|
||||
|
||||
const metadataCache = {
|
||||
get: (p) => store.get(p) || null,
|
||||
set: (p, m) => store.set(p, m),
|
||||
delete: (p) => store.delete(p),
|
||||
has: (p) => store.has(p),
|
||||
keys: () => [...store.keys()],
|
||||
};
|
||||
|
||||
const contentCache = {
|
||||
invalidate: vi.fn(),
|
||||
set: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: () => null,
|
||||
};
|
||||
|
||||
const fsWatch = { _dispatch: vi.fn() };
|
||||
const wsClient = { subscribe: vi.fn(), onReconnect: vi.fn() };
|
||||
const transport = { fetchTree: vi.fn() };
|
||||
|
||||
const client = createWatcherClient(
|
||||
metadataCache,
|
||||
contentCache,
|
||||
fsWatch,
|
||||
wsClient,
|
||||
transport,
|
||||
);
|
||||
|
||||
return { store, metadataCache, contentCache, fsWatch, wsClient, transport, client };
|
||||
}
|
||||
|
||||
describe("watcher-client reconcile", () => {
|
||||
it("adds a file present in the tree but missing from the cache", () => {
|
||||
const d = makeDeps();
|
||||
|
||||
d.client.reconcile({ "new.md": { type: "file", size: 5, mtime: 100, ctime: 50 } });
|
||||
|
||||
expect(d.store.get("new.md")).toMatchObject({ type: "file", size: 5 });
|
||||
expect(d.contentCache.invalidate).toHaveBeenCalledWith("new.md");
|
||||
expect(d.fsWatch._dispatch).toHaveBeenCalledWith("created", "new.md");
|
||||
});
|
||||
|
||||
it("adds a directory as a folder", () => {
|
||||
const d = makeDeps();
|
||||
|
||||
d.client.reconcile({ newdir: { type: "directory" } });
|
||||
|
||||
expect(d.store.get("newdir")).toEqual({ type: "directory" });
|
||||
expect(d.fsWatch._dispatch).toHaveBeenCalledWith("folder-created", "newdir");
|
||||
});
|
||||
|
||||
it("modifies a file whose mtime or size changed", () => {
|
||||
const d = makeDeps();
|
||||
d.store.set("a.md", { type: "file", size: 1, mtime: 10 });
|
||||
|
||||
d.client.reconcile({ "a.md": { type: "file", size: 2, mtime: 20, ctime: 5 } });
|
||||
|
||||
expect(d.store.get("a.md")).toMatchObject({ size: 2, mtime: 20 });
|
||||
expect(d.fsWatch._dispatch).toHaveBeenCalledWith("modified", "a.md");
|
||||
});
|
||||
|
||||
it("is a no-op for an unchanged file", () => {
|
||||
const d = makeDeps();
|
||||
d.store.set("a.md", { type: "file", size: 1, mtime: 10 });
|
||||
|
||||
d.client.reconcile({ "a.md": { type: "file", size: 1, mtime: 10, ctime: 5 } });
|
||||
|
||||
expect(d.fsWatch._dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes a cache entry absent from the tree and preserves the root", () => {
|
||||
const d = makeDeps();
|
||||
d.store.set("", { type: "directory" });
|
||||
d.store.set("gone.md", { type: "file", size: 1, mtime: 10 });
|
||||
d.store.set("keep.md", { type: "file", size: 1, mtime: 10 });
|
||||
|
||||
d.client.reconcile({ "keep.md": { type: "file", size: 1, mtime: 10, ctime: 5 } });
|
||||
|
||||
expect(d.store.has("gone.md")).toBe(false);
|
||||
expect(d.store.has("")).toBe(true);
|
||||
expect(d.fsWatch._dispatch).toHaveBeenCalledWith("deleted", "gone.md");
|
||||
expect(d.fsWatch._dispatch).not.toHaveBeenCalledWith("deleted", "keep.md");
|
||||
});
|
||||
|
||||
it("skips a path with a recent local op", () => {
|
||||
const d = makeDeps();
|
||||
const p = "recent-local-op-reconcile.md";
|
||||
markLocalOp(p);
|
||||
|
||||
d.client.reconcile({ [p]: { type: "file", size: 5, mtime: 100, ctime: 50 } });
|
||||
|
||||
expect(d.store.has(p)).toBe(false);
|
||||
expect(d.fsWatch._dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,15 @@ function installBuffer() {
|
||||
function installWindowClose() {
|
||||
window.close = function () {
|
||||
console.log("[ignis] window.close() blocked");
|
||||
|
||||
// Obsidian's quit flow shows the progress overlay, awaits its pending save work, then calls window.close().
|
||||
// Since we don't actually want to close the window, we clean up the progress state instead.
|
||||
if (document.body.classList.contains("in-progress")) {
|
||||
document.querySelector(".progress-bar-container")?.remove();
|
||||
document.body.classList.remove("in-progress");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.__vaultConfig) {
|
||||
showVaultManager();
|
||||
}
|
||||
|
||||
@@ -171,8 +171,7 @@ function applyCoreSyncGuard(plugins) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let text =
|
||||
typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
let text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
|
||||
try {
|
||||
const config = JSON.parse(text);
|
||||
@@ -208,15 +207,38 @@ function initCoreSyncGuardFallback() {
|
||||
}
|
||||
}
|
||||
|
||||
// Reflect the priority prefetch's byte progress on the boot splash so the awaited slice reads as active rather than hung.
|
||||
// The splash logo keeps pulsing through a transit stall, when the byte count would otherwise freeze.
|
||||
function updateBootProgress(received, total) {
|
||||
// Once the injector starts appending Obsidian's scripts it owns the splash label, so stop writing progress over it.
|
||||
if (window.__ignisBootStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = document.getElementById("ignis-status-label");
|
||||
|
||||
if (!label || !total) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
||||
label.textContent = `Loading plugins... ${mb(received)}/${mb(total)} MB`;
|
||||
}
|
||||
|
||||
// Resolve the active workspace and snapshot the appearance config.
|
||||
function resolveWorkspaceAndAppearance() {
|
||||
resolveWorkspaceName();
|
||||
loadPresetIfRequested();
|
||||
initNativeMenuGuard();
|
||||
}
|
||||
|
||||
export function initialize() {
|
||||
if (maybeProvisionDemoVault()) {
|
||||
window.__ignisBootReady = Promise.resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
resolveVaultId();
|
||||
resolveWorkspaceName();
|
||||
loadPresetIfRequested();
|
||||
initNativeMenuGuard(window.__currentVaultId);
|
||||
|
||||
const bootstrap = fetchBootstrap();
|
||||
|
||||
@@ -229,18 +251,26 @@ export function initialize() {
|
||||
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.
|
||||
prefetchVaultContent(
|
||||
// Warm the caches before Obsidian boots.
|
||||
// The priority slice (configs and plugin entry files) resolves window.__ignisBootReady, which the index.html injector waits on before appending Obsidian's scripts, so Obsidian's early reads hit the cache.
|
||||
// The bulk slice streams afterward without blocking boot.
|
||||
const { priority } = prefetchVaultContent(
|
||||
window.__currentVaultId,
|
||||
bootstrap.tree,
|
||||
fsShim._contentCache,
|
||||
{ onProgress: updateBootProgress },
|
||||
);
|
||||
|
||||
// Chain workspace/appearance resolution onto readiness so its config reads hit the warm priority slice instead of the network.
|
||||
window.__ignisBootReady = priority.then(resolveWorkspaceAndAppearance);
|
||||
} else {
|
||||
initVaultConfigFallback();
|
||||
initVaultListFallback();
|
||||
initMetadataCacheFallback();
|
||||
initCoreSyncGuardFallback();
|
||||
// No prefetch on the fallback path, so resolve directly; the reads fall through to the network.
|
||||
resolveWorkspaceAndAppearance();
|
||||
window.__ignisBootReady = Promise.resolve();
|
||||
}
|
||||
|
||||
installRequestUrlShim();
|
||||
|
||||
@@ -6,34 +6,16 @@ import {
|
||||
registerReadTransform,
|
||||
registerWriteTransform,
|
||||
} from "./fs/transforms.js";
|
||||
import { fsShim } from "./fs/index.js";
|
||||
|
||||
const APPEARANCE_PATH = ".obsidian/appearance.json";
|
||||
|
||||
// undefined = key absent on disk; write transform keeps it absent.
|
||||
let preservedNativeMenus = undefined;
|
||||
|
||||
function snapshotAppearance(vaultId) {
|
||||
if (!vaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
function snapshotAppearance() {
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const url =
|
||||
"/api/fs/readFile?vault=" +
|
||||
encodeURIComponent(vaultId) +
|
||||
"&path=" +
|
||||
encodeURIComponent(APPEARANCE_PATH) +
|
||||
"&encoding=utf-8";
|
||||
|
||||
xhr.open("GET", url, false);
|
||||
xhr.send();
|
||||
|
||||
if (xhr.status !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
const obj = JSON.parse(xhr.responseText);
|
||||
const obj = JSON.parse(fsShim.readFileSync(APPEARANCE_PATH, "utf-8"));
|
||||
|
||||
if ("nativeMenus" in obj) {
|
||||
preservedNativeMenus = obj.nativeMenus;
|
||||
@@ -158,9 +140,9 @@ function disableNativeMenuToggle() {
|
||||
});
|
||||
}
|
||||
|
||||
export function initNativeMenuGuard(vaultId) {
|
||||
// Snapshot before registering transforms so the write transform has the original disk value to substitute back.
|
||||
snapshotAppearance(vaultId);
|
||||
export function initNativeMenuGuard() {
|
||||
// Snapshot before registering the read transform so the captured value is the original on disk, not the forced value.
|
||||
snapshotAppearance();
|
||||
registerReadTransform(APPEARANCE_PATH, readTransform);
|
||||
registerWriteTransform(APPEARANCE_PATH, writeTransform);
|
||||
patchSetConfig();
|
||||
|
||||
@@ -86,71 +86,40 @@ export function loadPresetIfRequested() {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWorkspaceName() {
|
||||
function readJsonIfPresent(path) {
|
||||
try {
|
||||
const vaultParam = window.__currentVaultId
|
||||
? "?vault=" + encodeURIComponent(window.__currentVaultId)
|
||||
: "";
|
||||
return JSON.parse(fsShim.readFileSync(path, "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const sep = vaultParam ? "&" : "?";
|
||||
export function resolveWorkspaceName() {
|
||||
// With no URL param, only resolve a workspace when the workspaces core plugin is enabled.
|
||||
if (!window.__workspaceName) {
|
||||
const corePlugins = readJsonIfPresent(".obsidian/core-plugins.json");
|
||||
|
||||
// If no param provided, check if workspaces plugin is enabled before resolving.
|
||||
if (!window.__workspaceName) {
|
||||
const coreXhr = new XMLHttpRequest();
|
||||
|
||||
coreXhr.open(
|
||||
"GET",
|
||||
"/api/fs/readFile" +
|
||||
vaultParam +
|
||||
sep +
|
||||
"path=.obsidian/core-plugins.json&encoding=utf-8",
|
||||
false,
|
||||
);
|
||||
coreXhr.send();
|
||||
|
||||
if (coreXhr.status !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
const corePlugins = JSON.parse(coreXhr.responseText);
|
||||
|
||||
if (!corePlugins.workspaces) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Read workspaces.json to get the active field.
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open(
|
||||
"GET",
|
||||
"/api/fs/readFile" +
|
||||
vaultParam +
|
||||
sep +
|
||||
"path=.obsidian/workspaces.json&encoding=utf-8",
|
||||
false,
|
||||
);
|
||||
xhr.send();
|
||||
|
||||
if (xhr.status !== 200) {
|
||||
if (!corePlugins || !corePlugins.workspaces) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const workspaces = JSON.parse(xhr.responseText);
|
||||
const workspaces = readJsonIfPresent(WORKSPACES_PATH);
|
||||
|
||||
// Always store the original active value for the write transform.
|
||||
if (workspaces.active) {
|
||||
window.__originalActiveWorkspace = workspaces.active;
|
||||
}
|
||||
if (!workspaces) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If no param was provided, seed from the active workspace.
|
||||
if (!window.__workspaceName && workspaces.active) {
|
||||
window.__workspaceName = workspaces.active;
|
||||
setWorkspaceParam(workspaces.active);
|
||||
console.log("[ignis] Workspace resolved from active:", workspaces.active);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[ignis] Failed to resolve workspace name:", e);
|
||||
// Keep the original active value so the write transform can restore it on disk.
|
||||
if (workspaces.active) {
|
||||
window.__originalActiveWorkspace = workspaces.active;
|
||||
}
|
||||
|
||||
// With no URL param, seed from the active workspace.
|
||||
if (!window.__workspaceName && workspaces.active) {
|
||||
window.__workspaceName = workspaces.active;
|
||||
setWorkspaceParam(workspaces.active);
|
||||
console.log("[ignis] Workspace resolved from active:", workspaces.active);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ export function createWsClient() {
|
||||
let vaultId = null;
|
||||
let reconnectTimer = null;
|
||||
let manuallyClosed = false;
|
||||
let hasConnectedBefore = false;
|
||||
let state = "closed"; // "closed" | "connecting" | "open"
|
||||
|
||||
const globalSubs = new Map(); // type -> Set<handler>
|
||||
const channelSubs = new Map(); // channelName -> Map<type, Set<handler>>
|
||||
const channelSubCount = new Map(); // channelName -> integer
|
||||
const stateSubs = new Set(); // handler(state)
|
||||
const reconnectSubs = new Set(); // handler() fired on a re-open, not the first open
|
||||
|
||||
function setState(next) {
|
||||
if (state === next) {
|
||||
@@ -107,6 +109,20 @@ export function createWsClient() {
|
||||
for (const name of channelSubCount.keys()) {
|
||||
sendSubscribeChannel(name);
|
||||
}
|
||||
|
||||
// A re-open can miss watcher events that fired while the socket was down.
|
||||
// Boot covers the first open, so handlers fire only on later opens.
|
||||
if (hasConnectedBefore) {
|
||||
for (const fn of reconnectSubs) {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
console.error("[ws] reconnect subscriber threw:", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasConnectedBefore = true;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -252,6 +268,14 @@ export function createWsClient() {
|
||||
};
|
||||
}
|
||||
|
||||
function onReconnect(handler) {
|
||||
reconnectSubs.add(handler);
|
||||
|
||||
return () => {
|
||||
reconnectSubs.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
@@ -260,6 +284,7 @@ export function createWsClient() {
|
||||
channel,
|
||||
isOpen,
|
||||
onStateChange,
|
||||
onReconnect,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
63
packages/shim/src/ws-client.test.js
Normal file
63
packages/shim/src/ws-client.test.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createWsClient } from "./ws-client.js";
|
||||
|
||||
let sockets;
|
||||
|
||||
beforeEach(() => {
|
||||
sockets = [];
|
||||
|
||||
class FakeWebSocket {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = 0;
|
||||
sockets.push(this);
|
||||
}
|
||||
send() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
FakeWebSocket.OPEN = 1;
|
||||
globalThis.WebSocket = FakeWebSocket;
|
||||
globalThis.window = { location: { protocol: "http:", host: "localhost" } };
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete globalThis.WebSocket;
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
describe("ws-client reconnect", () => {
|
||||
it("fires onReconnect on a re-open but not on the first open", () => {
|
||||
const client = createWsClient();
|
||||
const onReconnect = vi.fn();
|
||||
client.onReconnect(onReconnect);
|
||||
|
||||
client.connect("v1");
|
||||
sockets[0].onopen();
|
||||
expect(onReconnect).not.toHaveBeenCalled();
|
||||
|
||||
sockets[0].onclose();
|
||||
vi.advanceTimersByTime(2000);
|
||||
sockets[1].onopen();
|
||||
|
||||
expect(onReconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops firing after unsubscribe", () => {
|
||||
const client = createWsClient();
|
||||
const onReconnect = vi.fn();
|
||||
const off = client.onReconnect(onReconnect);
|
||||
|
||||
client.connect("v1");
|
||||
sockets[0].onopen();
|
||||
off();
|
||||
|
||||
sockets[0].onclose();
|
||||
vi.advanceTimersByTime(2000);
|
||||
sockets[1].onopen();
|
||||
|
||||
expect(onReconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user