improve cold boot

This commit is contained in:
Nystik
2026-05-09 14:47:19 +02:00
parent e89f8d76fb
commit 6dfe2b5c81
6 changed files with 462 additions and 70 deletions

View File

@@ -7,8 +7,39 @@
<link href="app.css" type="text/css" rel="stylesheet"/>
<link rel="icon" type="image/png" href="favicon.png"/>
<link href="assets/overrides.css" type="text/css" rel="stylesheet"/>
<style>
#ignis-status {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
background: #202020;
color: #b3b3b3;
font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
z-index: 9999;
transition: opacity 200ms ease-out;
}
#ignis-status.fade { opacity: 0; pointer-events: none; }
#ignis-status img {
width: 96px;
height: 96px;
animation: ignis-pulse 1.6s ease-in-out infinite;
}
#ignis-status-label { font-size: 13px; opacity: 0.75; }
@keyframes ignis-pulse {
0%, 100% { opacity: 0.85; transform: scale(1); }
50% { opacity: 1; transform: scale(1.04); }
}
</style>
</head>
<body class="theme-dark">
<div id="ignis-status">
<img src="favicon.png" alt=""/>
<div id="ignis-status-label">Loading Obsidian...</div>
</div>
<!-- Ignis shims: must run before any Obsidian code. -->
<script type="text/javascript" src="__IGNIS_UI_SRC__"></script>
<script type="text/javascript" src="__SHIM_LOADER_SRC__"></script>
@@ -16,11 +47,41 @@
<script>
(function () {
var scripts = __OBSIDIAN_SCRIPTS__;
var label = document.getElementById("ignis-status-label");
var status = document.getElementById("ignis-status");
var loaded = 0;
function update() {
if (label) {
label.textContent = "Loading Obsidian " + loaded + "/" + scripts.length;
}
}
function done() {
if (!status) return;
status.classList.add("fade");
setTimeout(function () {
if (status && status.parentNode) status.parentNode.removeChild(status);
}, 250);
}
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);
}
})();

View File

@@ -52,6 +52,7 @@ const fsRoutes = require("./routes/fs");
const vaultRoutes = require("./routes/vault");
const proxyRoutes = require("./routes/proxy");
const versionRoutes = require("./routes/version");
const bootstrapRoutes = require("./routes/bootstrap");
app.use("/assets", express.static(path.join(__dirname, "assets")));
@@ -60,6 +61,7 @@ app.use("/api/vault", vaultRoutes);
app.use("/api/proxy", proxyRoutes);
app.use("/api/version", versionRoutes);
app.use("/api/plugins", pluginRoutes);
app.use("/api/bootstrap", bootstrapRoutes);
// Serve vault files for resource URLs (images, attachments, etc.)
// Vault ID is the first path segment: /vault-files/<vault-id>/path/to/file
@@ -153,6 +155,9 @@ const server = app.listen(config.port, async () => {
await updateBridgePluginInAllVaults(config.vaultRoot);
await initPlugins({ app, config, wss, watcher });
bootstrapRoutes.warmUp().catch((e) =>
console.warn("[bootstrap] warm-up error:", e.message),
);
});
const wss = setupWebSocket(server);

252
server/routes/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,252 @@
// Bootstrap endpoint for cold start.
//
// Combines vault info, vault list, metadata tree, and plugin list into a
// single pre-compressed response. Cache is per-vault and invalidated by
// directory mtime check + explicit invalidateVault() calls from the write/delete routes.
const express = require("express");
const fs = require("fs");
const fsp = fs.promises;
const path = require("path");
const zlib = require("zlib");
const config = require("../config");
const { isBridgePluginInstalled, getIgnisMeta } = require("../bridge-plugin");
const { getDiscoveredPlugins } = require("../plugin-system/manager");
const router = express.Router();
// vaultId -> { response, dirMtimes, compressed: { br, gz } }
const cache = new Map();
// vaultId -> Promise<entry> (in-flight build dedup)
const pendingBuilds = new Map();
function preCompress(buf) {
return Promise.all([
new Promise((resolve, reject) => {
zlib.brotliCompress(
buf,
{ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } },
(err, result) => (err ? reject(err) : resolve(result)),
);
}),
new Promise((resolve, reject) => {
zlib.gzip(buf, { level: 6 }, (err, result) =>
err ? reject(err) : resolve(result),
);
}),
]).then(([br, gz]) => ({ br, gz }));
}
async function walkTree(rootPath) {
const tree = {};
const dirMtimes = {};
async function walk(dir, prefix) {
const stat = await fsp.stat(dir);
dirMtimes[prefix] = stat.mtimeMs;
const entries = await fsp.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const rel = prefix ? prefix + "/" + entry.name : entry.name;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
tree[rel] = { type: "directory" };
await walk(full, rel);
} else {
try {
const s = await fsp.stat(full);
tree[rel] = {
type: "file",
size: s.size,
mtime: s.mtimeMs,
ctime: s.ctimeMs,
};
} catch {
tree[rel] = { type: "file" };
}
}
}
}
await walk(rootPath, "");
return { tree, dirMtimes };
}
async function buildVaultInfo(vaultId, vaultPath) {
const pluginInstalled = await isBridgePluginInstalled(vaultPath);
const ignisMeta = await getIgnisMeta(vaultPath);
return {
id: vaultId,
name: vaultId,
path: vaultPath,
platform: process.platform,
version: config.obsidianVersion,
ignisPlugin: {
installed: pluginInstalled,
prompted: ignisMeta.pluginPrompted || false,
},
};
}
function buildVaultList() {
return Object.entries(config.vaults).map(([id, vaultPath]) => ({
id,
name: id,
path: vaultPath,
}));
}
async function dirMtimesUnchanged(vaultPath, dirMtimes) {
const checks = await Promise.all(
Object.entries(dirMtimes).map(async ([relDir, oldMtime]) => {
const absDir = relDir
? path.join(vaultPath, relDir.split("/").join(path.sep))
: vaultPath;
try {
const s = await fsp.stat(absDir);
return s.mtimeMs === oldMtime;
} catch {
return false;
}
}),
);
return checks.every(Boolean);
}
async function buildEntry(vaultId) {
const vaultPath = config.getVaultPath(vaultId);
if (!vaultPath) {
return null;
}
const cached = cache.get(vaultId);
if (cached && (await dirMtimesUnchanged(vaultPath, cached.dirMtimes))) {
return cached;
}
const t0 = Date.now();
const [vault, { tree, dirMtimes }] = await Promise.all([
buildVaultInfo(vaultId, vaultPath),
walkTree(vaultPath),
]);
const response = {
vault,
vaultList: buildVaultList(),
tree,
plugins: getDiscoveredPlugins(),
};
const jsonBuf = Buffer.from(JSON.stringify(response));
let compressed = {};
try {
compressed = await preCompress(jsonBuf);
} catch (e) {
console.warn("[bootstrap] precompression failed:", e.message);
}
const entry = { response, dirMtimes, compressed };
cache.set(vaultId, entry);
const ms = Date.now() - t0;
const fileCount = Object.keys(tree).filter(
(k) => tree[k].type === "file",
).length;
const dirCount = Object.keys(dirMtimes).length;
console.log(
`[bootstrap] vault=${vaultId} build files=${fileCount} dirs=${dirCount} time=${ms}ms`,
);
return entry;
}
async function getOrBuild(vaultId) {
if (pendingBuilds.has(vaultId)) {
return pendingBuilds.get(vaultId);
}
const promise = buildEntry(vaultId).finally(() => {
pendingBuilds.delete(vaultId);
});
pendingBuilds.set(vaultId, promise);
return promise;
}
function invalidateVault(vaultId) {
cache.delete(vaultId);
}
async function warmUp() {
const ids = Object.keys(config.vaults);
for (const id of ids) {
try {
await buildEntry(id);
} catch (e) {
console.warn(`[bootstrap] warm-up failed for vault ${id}:`, e.message);
}
}
}
router.get("/", async (req, res) => {
const vaultId = req.query.vault || config.defaultVaultId;
if (!vaultId || !config.getVaultPath(vaultId)) {
return res.status(404).json({ error: "Vault not found", id: vaultId });
}
try {
const entry = await getOrBuild(vaultId);
if (!entry) {
return res.status(404).json({ error: "Vault not found" });
}
const ae = req.headers["accept-encoding"] || "";
const { compressed } = entry;
let buf, encoding;
if (ae.includes("br") && compressed.br) {
buf = compressed.br;
encoding = "br";
} else if (
(ae.includes("gzip") || ae.includes("deflate")) &&
compressed.gz
) {
buf = compressed.gz;
encoding = "gzip";
}
if (buf) {
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader("Content-Encoding", encoding);
res.setHeader("Content-Length", buf.length);
res.setHeader("Cache-Control", "no-cache");
return res.status(200).end(buf);
}
res.json(entry.response);
} catch (e) {
console.error("[bootstrap] error:", e);
res.status(500).json({ error: e.message });
}
});
module.exports = router;
module.exports.invalidateVault = invalidateVault;
module.exports.warmUp = warmUp;

View File

@@ -4,6 +4,7 @@ const path = require("path");
const archiver = require("archiver");
const config = require("../config");
const { writeCoalesced, getPending } = require("../write-coalescer");
const bootstrapRoutes = require("./bootstrap");
const router = express.Router();
@@ -64,9 +65,17 @@ function getVaultRoot(req, res) {
res.status(404).json({ error: "Vault not found", id: vaultId });
return null;
}
req._vaultId = vaultId;
return vaultPath;
}
function invalidateBootstrap(req) {
if (req._vaultId) {
bootstrapRoutes.invalidateVault(req._vaultId);
}
}
// Resolve a client-provided path to an absolute path within a vault.
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
function resolveVaultPath(vaultRoot, relativePath) {
@@ -258,6 +267,7 @@ router.post("/writeFile", async (req, res) => {
const result = await writeCoalesced(resolved, data, encoding);
invalidateBootstrap(req);
res.json({ ok: true, mtime: result.mtime, size: result.size });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
@@ -275,6 +285,7 @@ router.post("/appendFile", async (req, res) => {
try {
await fs.promises.appendFile(resolved, req.body.content, "utf-8");
invalidateBootstrap(req);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
@@ -294,6 +305,7 @@ router.post("/mkdir", async (req, res) => {
recursive: !!req.body.recursive,
});
invalidateBootstrap(req);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
@@ -318,6 +330,7 @@ router.post("/rename", async (req, res) => {
try {
await fs.promises.rename(oldResolved, newResolved);
invalidateBootstrap(req);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
@@ -342,6 +355,7 @@ router.post("/copyFile", async (req, res) => {
try {
await fs.promises.copyFile(srcResolved, destResolved);
invalidateBootstrap(req);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
@@ -359,6 +373,7 @@ router.delete("/unlink", async (req, res) => {
try {
await fs.promises.unlink(resolved);
invalidateBootstrap(req);
res.json({ ok: true });
} catch (e) {
if (e.code === "ENOENT") {
@@ -381,6 +396,7 @@ router.delete("/rmdir", async (req, res) => {
try {
await fs.promises.rmdir(resolved);
invalidateBootstrap(req);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
@@ -399,6 +415,8 @@ router.delete("/rm", async (req, res) => {
await fs.promises.rm(resolved, {
recursive: req.query.recursive === "true",
});
invalidateBootstrap(req);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
@@ -453,6 +471,8 @@ router.post("/utimes", async (req, res) => {
req.body.atime / 1000,
req.body.mtime / 1000,
);
invalidateBootstrap(req);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });

View File

@@ -8,6 +8,7 @@ const {
setIgnisMeta,
installBridgePlugin,
} = require("../bridge-plugin");
const bootstrapRoutes = require("./bootstrap");
const router = express.Router();
@@ -68,6 +69,7 @@ router.post("/create", async (req, res) => {
await installBridgePlugin(vaultPath);
config.refreshVaults();
bootstrapRoutes.invalidateVault(name);
res.json({ ok: true, id: name, path: vaultPath });
} catch (e) {
@@ -100,6 +102,8 @@ router.post("/rename", async (req, res) => {
await fs.promises.rename(vaultPath, newPath);
config.refreshVaults();
bootstrapRoutes.invalidateVault(vaultId);
bootstrapRoutes.invalidateVault(newName);
res.json({ ok: true, id: newName, path: newPath });
} catch (e) {
@@ -126,6 +130,7 @@ router.delete("/remove", async (req, res) => {
await fs.promises.rm(vaultPath, { recursive: true });
config.refreshVaults();
bootstrapRoutes.invalidateVault(vaultId);
res.json({ ok: true });
} catch (e) {