consolidate build scripts, reorganize source into src/ directory, fix favicon injection

This commit is contained in:
Nystik
2026-03-20 23:46:17 +01:00
parent 2add5238b8
commit 0747a4540d
56 changed files with 46 additions and 45 deletions

20
src/shims/fs/constants.js Normal file
View File

@@ -0,0 +1,20 @@
// Node.js fs.constants equivalents
export const constants = {
F_OK: 0,
R_OK: 4,
W_OK: 2,
X_OK: 1,
COPYFILE_EXCL: 1,
COPYFILE_FICLONE: 2,
COPYFILE_FICLONE_FORCE: 4,
O_RDONLY: 0,
O_WRONLY: 1,
O_RDWR: 2,
O_CREAT: 64,
O_EXCL: 128,
O_TRUNC: 512,
O_APPEND: 1024,
};

View File

@@ -0,0 +1,95 @@
// In-memory content cache with simple LRU eviction
// Stores file content fetched from the server.
const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 50 MB
export class ContentCache {
constructor(maxSize = DEFAULT_MAX_SIZE) {
this._cache = new Map(); // path -> { data, size, accessedAt }
this._currentSize = 0;
this._maxSize = maxSize;
}
has(path) {
return this._cache.has(this._normalize(path));
}
get(path) {
const entry = this._cache.get(this._normalize(path));
if (entry) {
entry.accessedAt = Date.now();
return entry.data;
}
return null;
}
set(path, data) {
const norm = this._normalize(path);
const size = data ? data.length || data.byteLength || 0 : 0;
// Remove old entry if replacing
if (this._cache.has(norm)) {
this._currentSize -= this._cache.get(norm).size;
}
// Evict LRU entries if needed
while (this._currentSize + size > this._maxSize && this._cache.size > 0) {
this._evictOne();
}
this._cache.set(norm, { data, size, accessedAt: Date.now() });
this._currentSize += size;
}
delete(path) {
const norm = this._normalize(path);
const entry = this._cache.get(norm);
if (entry) {
this._currentSize -= entry.size;
this._cache.delete(norm);
}
}
// Invalidate a path (remove from cache so next read fetches fresh)
invalidate(path) {
this.delete(path);
}
clear() {
this._cache.clear();
this._currentSize = 0;
}
get size() {
return this._cache.size;
}
get currentBytes() {
return this._currentSize;
}
_evictOne() {
let oldest = null;
let oldestTime = Infinity;
for (const [key, entry] of this._cache) {
if (entry.accessedAt < oldestTime) {
oldest = key;
oldestTime = entry.accessedAt;
}
}
if (oldest) {
this.delete(oldest);
}
}
_normalize(p) {
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
}

38
src/shims/fs/index.js Normal file
View File

@@ -0,0 +1,38 @@
import { MetadataCache } from "./metadata-cache.js";
import { ContentCache } from "./content-cache.js";
import { transport } from "./transport.js";
import { createFsPromises } from "./promises.js";
import { createFsSync } from "./sync.js";
import { createFsWatch } from "./watch.js";
import { constants } from "./constants.js";
const metadataCache = new MetadataCache();
const contentCache = new ContentCache();
const fsPromises = createFsPromises(metadataCache, contentCache, transport);
const fsSync = createFsSync(metadataCache, contentCache, transport);
const fsWatch = createFsWatch(transport);
export const fsShim = {
promises: fsPromises,
existsSync: fsSync.existsSync,
readFileSync: fsSync.readFileSync,
writeFileSync: fsSync.writeFileSync,
unlinkSync: fsSync.unlinkSync,
accessSync: fsSync.accessSync,
statSync: fsSync.statSync,
readdirSync: fsSync.readdirSync,
watch: fsWatch.watch,
constants,
_metadataCache: metadataCache,
_contentCache: contentCache,
async _init(basePath) {
const tree = await transport.fetchTree(basePath);
metadataCache.populate(tree);
console.log(`[shim:fs] Initialized with ${metadataCache.size} entries`);
},
};

View File

@@ -0,0 +1,123 @@
// In-memory metadata cache
// Populated from /api/fs/tree on startup, kept in sync via transport events.
// All stat/exists/readdir calls are served from this cache.
export class MetadataCache {
constructor() {
// Map<string, { type: 'file'|'directory', size: number, mtime: number, ctime: number }>
this._entries = new Map();
}
// Populate from a server-provided tree object
// tree shape: { "relative/path": { type, size, mtime, ctime }, ... }
populate(tree) {
this._entries.clear();
for (const [path, meta] of Object.entries(tree)) {
this._entries.set(this._normalize(path), meta);
}
}
has(path) {
return this._entries.has(this._normalize(path));
}
get(path) {
return this._entries.get(this._normalize(path)) || null;
}
set(path, meta) {
this._entries.set(this._normalize(path), meta);
}
delete(path) {
this._entries.delete(this._normalize(path));
}
// Rename: move metadata from old path to new path (and children if directory)
rename(oldPath, newPath) {
const oldNorm = this._normalize(oldPath);
const newNorm = this._normalize(newPath);
const meta = this._entries.get(oldNorm);
if (meta) {
this._entries.delete(oldNorm);
this._entries.set(newNorm, meta);
}
// Move children
const prefix = oldNorm + "/";
for (const [key, val] of this._entries) {
if (key.startsWith(prefix)) {
const newKey = newNorm + "/" + key.slice(prefix.length);
this._entries.delete(key);
this._entries.set(newKey, val);
}
}
}
// List direct children of a directory path
readdir(dirPath) {
const norm = this._normalize(dirPath);
const prefix = norm === "" ? "" : norm + "/";
const results = [];
const seen = new Set();
for (const [key, meta] of this._entries) {
if (prefix === "" || key.startsWith(prefix)) {
const rest = key.slice(prefix.length);
const slashIdx = rest.indexOf("/");
const childName = slashIdx >= 0 ? rest.slice(0, slashIdx) : rest;
if (childName && !seen.has(childName)) {
seen.add(childName);
const childMeta = this._entries.get(prefix + childName);
results.push({
name: childName,
type: childMeta?.type || (slashIdx >= 0 ? "directory" : "file"),
});
}
}
}
return results;
}
get size() {
return this._entries.size;
}
toStat(path) {
const meta = this.get(path);
if (!meta) {
return null;
}
return {
size: meta.size || 0,
mtimeMs: meta.mtime || 0,
ctimeMs: meta.ctime || 0,
atimeMs: meta.mtime || 0,
birthtimeMs: meta.ctime || 0,
mtime: new Date(meta.mtime || 0),
ctime: new Date(meta.ctime || 0),
atime: new Date(meta.mtime || 0),
birthtime: new Date(meta.ctime || 0),
isFile: () => meta.type === "file",
isDirectory: () => meta.type === "directory",
isSymbolicLink: () => false,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isFIFO: () => false,
isSocket: () => false,
};
}
_normalize(p) {
// Normalize slashes, remove leading and trailing slashes
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
}

201
src/shims/fs/promises.js Normal file
View File

@@ -0,0 +1,201 @@
export function createFsPromises(metadataCache, contentCache, transport) {
return {
async stat(path) {
const cached = metadataCache.toStat(path);
if (cached) {
return cached;
}
const meta = await transport.stat(path);
metadataCache.set(path, meta);
return metadataCache.toStat(path);
},
async lstat(path) {
// No symlinks in our context
return this.stat(path);
},
async readdir(path) {
const meta = metadataCache.get(path);
if (meta && meta.type === "file") {
return [];
}
if (!meta && path && path !== "/" && path !== ".") {
const e = new Error(
`ENOENT: no such file or directory, scandir '${path}'`,
);
e.code = "ENOENT";
throw e;
}
const entries = metadataCache.readdir(path);
return entries.map((e) => e.name);
},
async readFile(path, encoding) {
if (typeof encoding === "object") {
encoding = encoding?.encoding;
}
const wantText = encoding === "utf8" || encoding === "utf-8";
const meta = metadataCache.get(path);
if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read");
e.code = "EISDIR";
throw e;
}
if (!meta && path) {
const e = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);
e.code = "ENOENT";
throw e;
}
const cached = contentCache.get(path);
if (cached !== null) {
if (wantText) {
return typeof cached === "string"
? cached
: new TextDecoder().decode(cached);
}
// binary. ensure we return a proper Uint8Array with .buffer
if (typeof cached === "string") {
return new TextEncoder().encode(cached);
}
return cached;
}
const data = await transport.readFile(path, encoding);
contentCache.set(path, data);
return data;
},
async writeFile(path, data, encoding) {
if (typeof encoding === "object") {
encoding = encoding?.encoding;
}
contentCache.set(path, data);
const size =
typeof data === "string" ? data.length : data.byteLength || 0;
metadataCache.set(path, {
type: "file",
size,
mtime: Date.now(),
ctime: metadataCache.get(path)?.ctime || Date.now(),
});
const result = await transport.writeFile(path, data, encoding);
if (result.mtime) {
metadataCache.set(path, {
type: "file",
size: result.size || size,
mtime: result.mtime,
ctime: metadataCache.get(path)?.ctime || Date.now(),
});
}
},
async appendFile(path, data, encoding) {
contentCache.invalidate(path);
await transport.appendFile(path, data);
const meta = await transport.stat(path);
metadataCache.set(path, meta);
},
async unlink(path) {
contentCache.delete(path);
metadataCache.delete(path);
await transport.unlink(path);
},
async rename(oldPath, newPath) {
const content = contentCache.get(oldPath);
if (content !== null) {
contentCache.set(newPath, content);
contentCache.delete(oldPath);
}
metadataCache.rename(oldPath, newPath);
await transport.rename(oldPath, newPath);
},
async mkdir(path, options) {
const recursive =
typeof options === "object" ? !!options.recursive : !!options;
metadataCache.set(path, { type: "directory" });
await transport.mkdir(path, recursive);
},
async rmdir(path) {
metadataCache.delete(path);
await transport.rmdir(path);
},
async rm(path, options) {
const recursive =
typeof options === "object" ? !!options.recursive : false;
metadataCache.delete(path);
contentCache.delete(path);
await transport.rm(path, recursive);
},
async copyFile(src, dest) {
await transport.copyFile(src, dest);
const meta = await transport.stat(dest);
metadataCache.set(dest, meta);
},
async access(path) {
if (metadataCache.has(path)) {
return;
}
const e = new Error(
`ENOENT: no such file or directory, access '${path}'`,
);
e.code = "ENOENT";
throw e;
},
async realpath(path) {
if (!path || path === "/" || path === ".") {
return "/";
}
return transport.realpath(path);
},
async utimes(path, atime, mtime) {
await transport.utimes(path, atime, mtime);
const meta = metadataCache.get(path);
if (meta) {
meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime();
metadataCache.set(path, meta);
}
},
};
}

110
src/shims/fs/sync.js Normal file
View File

@@ -0,0 +1,110 @@
export function createFsSync(metadataCache, contentCache, transport) {
return {
existsSync(path) {
return metadataCache.has(path);
},
statSync(path) {
const stat = metadataCache.toStat(path);
if (!stat) {
const err = new Error(
`ENOENT: no such file or directory, stat '${path}'`,
);
err.code = "ENOENT";
throw err;
}
return stat;
},
accessSync(path, mode) {
if (!metadataCache.has(path)) {
const err = new Error(
`ENOENT: no such file or directory, access '${path}'`,
);
err.code = "ENOENT";
throw err;
}
},
readFileSync(path, encoding) {
if (typeof encoding === "object") {
encoding = encoding?.encoding;
}
const meta = metadataCache.get(path);
if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read");
e.code = "EISDIR";
throw e;
}
const cached = contentCache.get(path);
if (cached !== null) {
if (encoding === "utf8" || encoding === "utf-8") {
return typeof cached === "string"
? cached
: new TextDecoder().decode(cached);
}
return cached;
}
console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path);
const data = transport.readFileSync(path, encoding);
contentCache.set(path, data);
return data;
},
writeFileSync(path, data, encoding) {
if (typeof encoding === "object") {
encoding = encoding?.encoding;
}
contentCache.set(path, data);
const size =
typeof data === "string" ? data.length : data.byteLength || 0;
metadataCache.set(path, {
type: "file",
size,
mtime: Date.now(),
ctime: metadataCache.get(path)?.ctime || Date.now(),
});
// Fire-and-forget async send to server
transport.writeFile(path, data, encoding).catch((e) => {
console.error(
"[shim:fs] writeFileSync background save failed:",
path,
e,
);
});
},
unlinkSync(path) {
contentCache.delete(path);
metadataCache.delete(path);
// Fire-and-forget. suppress ENOENT (file already gone)
transport.unlink(path).catch((e) => {
if (e.code !== "ENOENT") {
console.error(
"[shim:fs] unlinkSync background delete failed:",
path,
e,
);
}
});
},
readdirSync(path) {
const entries = metadataCache.readdir(path);
return entries.map((e) => e.name);
},
};
}

224
src/shims/fs/transport.js Normal file
View File

@@ -0,0 +1,224 @@
const API_BASE = "/api/fs";
function normPath(p) {
return (p || "").replace(/^\/+/, "");
}
function uint8ToBase64(bytes) {
let binary = "";
const chunk = 8192;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
function vaultId() {
return window.__currentVaultId || "";
}
async function request(method, endpoint, params = {}) {
const url = new URL(API_BASE + endpoint, window.location.origin);
const options = { method };
if (method === "GET" || method === "DELETE") {
if (vaultId()) {
url.searchParams.set("vault", vaultId());
}
for (const [key, val] of Object.entries(params)) {
url.searchParams.set(key, val);
}
} else {
options.headers = { "Content-Type": "application/json" };
options.body = JSON.stringify({ vault: vaultId(), ...params });
}
const res = await fetch(url.toString(), options);
if (!res.ok) {
const err = await res
.json()
.catch(() => ({ error: res.statusText, code: "UNKNOWN" }));
const e = new Error(err.error || res.statusText);
e.code = err.code || "UNKNOWN";
throw e;
}
return res;
}
async function requestJson(method, endpoint, params = {}) {
const res = await request(method, endpoint, params);
return res.json();
}
function requestSync(method, endpoint, params = {}) {
const url = new URL(API_BASE + endpoint, window.location.origin);
if (method === "GET" || method === "DELETE") {
if (vaultId()) {
url.searchParams.set("vault", vaultId());
}
for (const [key, val] of Object.entries(params)) {
url.searchParams.set(key, val);
}
}
const xhr = new XMLHttpRequest();
xhr.open(method, url.toString(), false); // synchronous
if (method !== "GET" && method !== "DELETE") {
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ vault: vaultId(), ...params }));
} else {
xhr.send();
}
if (xhr.status >= 400) {
let err;
try {
const body = JSON.parse(xhr.responseText);
err = new Error(body.error || "Request failed");
err.code = body.code || "UNKNOWN";
} catch {
err = new Error("Request failed: " + xhr.status);
err.code = "UNKNOWN";
}
throw err;
}
return xhr;
}
export const transport = {
async fetchTree(basePath) {
return requestJson("GET", "/tree", basePath ? { path: basePath } : {});
},
async stat(path) {
return requestJson("GET", "/stat", { path: normPath(path) });
},
async readdir(path) {
return requestJson("GET", "/readdir", { path: normPath(path) });
},
async readFile(path, encoding) {
const res = await request("GET", "/readFile", {
path: normPath(path),
encoding: encoding || "",
});
if (encoding === "utf8" || encoding === "utf-8") {
return res.text();
}
const buf = await res.arrayBuffer();
return new Uint8Array(buf);
},
async writeFile(path, content, encoding) {
const isText = typeof content === "string";
return requestJson("POST", "/writeFile", {
path: normPath(path),
content: isText ? content : uint8ToBase64(content),
encoding: encoding || (isText ? "utf-8" : "binary"),
base64: !isText,
});
},
async appendFile(path, content) {
return requestJson("POST", "/appendFile", {
path: normPath(path),
content,
});
},
async mkdir(path, recursive) {
return requestJson("POST", "/mkdir", { path: normPath(path), recursive });
},
async rename(oldPath, newPath) {
return requestJson("POST", "/rename", {
oldPath: normPath(oldPath),
newPath: normPath(newPath),
});
},
async copyFile(src, dest) {
return requestJson("POST", "/copyFile", {
src: normPath(src),
dest: normPath(dest),
});
},
async unlink(path) {
return requestJson("DELETE", "/unlink", { path: normPath(path) });
},
async rmdir(path) {
return requestJson("DELETE", "/rmdir", { path: normPath(path) });
},
async rm(path, recursive) {
return requestJson("DELETE", "/rm", {
path: normPath(path),
recursive: recursive ? "true" : "false",
});
},
async access(path) {
return requestJson("GET", "/access", { path: normPath(path) });
},
async realpath(path) {
const result = await requestJson("GET", "/realpath", {
path: normPath(path),
});
return result.path;
},
async utimes(path, atime, mtime) {
return requestJson("POST", "/utimes", {
path: normPath(path),
atime,
mtime,
});
},
readFileSync(path, encoding) {
const xhr = requestSync("GET", "/readFile", {
path: normPath(path),
encoding: encoding || "",
});
if (encoding === "utf8" || encoding === "utf-8") {
return xhr.responseText;
}
const binary = xhr.responseText;
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
},
writeFileSync(path, content, encoding) {
const isText = typeof content === "string";
requestSync("POST", "/writeFile", {
path: normPath(path),
content: isText ? content : uint8ToBase64(content),
encoding: encoding || (isText ? "utf-8" : "binary"),
base64: !isText,
});
},
};

58
src/shims/fs/watch.js Normal file
View File

@@ -0,0 +1,58 @@
export function createFsWatch(transport) {
const watchers = new Map(); // path -> Set<listener>
return {
watch(path, options, listener) {
if (typeof options === "function") {
listener = options;
options = {};
}
if (!watchers.has(path)) {
watchers.set(path, new Set());
}
watchers.get(path).add(listener);
// TODO: send watch subscription to server via transport
// Return a watcher-like object
return {
close() {
const set = watchers.get(path);
if (set) {
set.delete(listener);
if (set.size === 0) {
watchers.delete(path);
// TODO: send unwatch to server
}
}
},
on() {
return this;
},
once() {
return this;
},
removeListener() {
return this;
},
};
},
// Internal: called when transport receives a file-change event
_dispatch(eventType, filePath) {
for (const [watchPath, listeners] of watchers) {
if (filePath === watchPath || filePath.startsWith(watchPath + "/")) {
const relativeName = filePath.slice(watchPath.length + 1) || filePath;
for (const fn of listeners) {
try {
fn(eventType, relativeName);
} catch (e) {
console.error("[shim:fs:watch] Listener error:", e);
}
}
}
}
},
};
}