Package Exports
- @mfukala/wsfs
- @mfukala/wsfs/client
- @mfukala/wsfs/server
Readme
wsfs
wsfs (Web Sync File System) is local-first virtual filesystem with IndexedDB caching, optimistic concurrency, and pluggable backend persistence.
The package ships:
- A browser client (
@mfukala/wsfs/client) that caches reads/writes locally, syncs in batches, and surfaces conflicts - A server toolkit (
@mfukala/wsfs/server) that wires the sync protocol into any persistence adapter (an in-memory adapter is included) - No runtime dependencies
Browser client (@mfukala/wsfs/client)
import { Wsfs } from "@mfukala/wsfs/client";
const wsfs = await Wsfs.init({
namespace: "vault", // IndexedDB namespace
backendUrl: "http://localhost:8787", // your API base URL
});
// Mutations run inside a write task (exclusive lock + rollback on task failure).
await wsfs.runWriteTask(async (fs) => {
await fs.write("/primary/item.json", JSON.stringify({ hello: "world" }));
});
// Reads run in read tasks (can run concurrently).
const item = await wsfs.runReadTask((fs) => fs.read("/primary/item.json"));
// Batch push local changes and pull remote updates.
await wsfs.sync();runWriteTaskAndSynckeeps the write lock through the sync; local edits rollback only if the task throws, not if sync fails.list(prefix?)hides local tombstones;info(path)returns{ etag, encoding, updatedBy? }.- Conflicts surface via a
conflictevent:
wsfs.addEventListener("conflict", (event) => {
console.warn("Conflict detected", event.detail);
});- Pass a
codec(encode/decode) to encrypt/compress payloads before hitting storage or the network.
Server toolkit (@mfukala/wsfs/server)
Hook the protocol into any Node.js framework. createWsfsApi exposes the core sync/read/write/delete/list methods and works with any PersistenceAdapter. An in-memory adapter ships with the package for testing.
Express server example
import express from "express";
import { createWsfsApi, MemoryPersistence } from "@mfukala/wsfs/server";
const persistence = new MemoryPersistence(); // bring your own adapter in production
const api = createWsfsApi(persistence);
const app = express();
app.use(express.json({ limit: "5mb" }));
app.post("/sync", async (req, res) => {
try {
res.status(200).json(await api.sync(req.body));
} catch (err: any) {
res.status(err?.status ?? 500).json({ error: err?.message ?? "sync failed" });
}
});
app.get("/file", async (req, res) => {
const file = await api.getFile(String(req.query.path ?? ""));
if (!file) return res.status(404).end();
res.json(file);
});
app.get("/file/info", async (req, res) => {
const info = await api.getFileInfo(String(req.query.path ?? ""));
if (!info) return res.status(404).end();
res.json(info);
});
app.get("/list", async (req, res) => {
res.json(await api.list(String(req.query.prefix ?? "/")));
});
app.put("/file", async (req, res) => {
try {
const result = await api.putFile({
path: req.body.path,
content: req.body.content,
contentBase64: req.body.contentBase64,
encoding: req.body.encoding,
ifMatch: req.headers["if-match"] as string | undefined,
updatedBy: req.body.updatedBy,
});
res.status(200).json(result);
} catch (err: any) {
res.status(err?.status ?? 500).json({ error: err?.message ?? "put failed" });
}
});
app.delete("/file", async (req, res) => {
try {
await api.deleteFile({
path: String(req.query.path ?? ""),
ifMatch: req.headers["if-match"] as string | undefined,
});
res.status(204).end();
} catch (err: any) {
res.status(err?.status ?? 500).json({ error: err?.message ?? "delete failed" });
}
});
app.listen(8787);Next.js API Route example
// pages/api/wsfs/sync.ts (or app/api/wsfs/sync/route.ts)
import { createWsfsApi, MemoryPersistence } from "@mfukala/wsfs/server";
const api = createWsfsApi(new MemoryPersistence()); // swap in your adapter
export default async function handler(req, res) {
try {
const result = await api.sync(req.body);
res.status(200).json(result);
} catch (err: any) {
res.status(err?.status ?? 500).json({ error: err?.message ?? "sync failed" });
}
}Reuse the same pattern for /file, /file/info, and /list, passing the request payloads into api.putFile, api.getFile, api.getFileInfo, and api.list while preserving the If-Match header for writes/deletes.
Server endpoints the client expects
POST /syncwith{ prefix, writes, deletes, known, watermark }GET /file?path=/foo.txt→{ etag, encoding, updatedBy?, content|contentBase64 }GET /file/info?path=/foo.txt→{ etag, encoding, updatedBy? }GET /list?prefix=/dir/→[{ path, etag, encoding }]PUT /file+If-Match: <etag|*>→{ etag }DELETE /file?path=...+If-Match: <etag|*>
The bundled MemoryPersistence enforces If-Match (use "*" to create new files), tracks updatedBy, and exposes incremental sync via watermarks.