diff --git a/packages/shim/src/fs/callback.js b/packages/shim/src/fs/callback.js new file mode 100644 index 0000000..a39be41 --- /dev/null +++ b/packages/shim/src/fs/callback.js @@ -0,0 +1,34 @@ +const CALLBACK_METHODS = [ + "stat", + "lstat", + "readdir", + "readFile", + "writeFile", + "appendFile", + "unlink", + "rename", + "mkdir", + "rmdir", + "rm", + "copyFile", + "access", + "utimes", + "chmod", +]; + +export function createFsCallbacks(fsPromises) { + const callbacks = {}; + + for (const name of CALLBACK_METHODS) { + callbacks[name] = function (...args) { + const callback = args.pop(); + + fsPromises[name](...args).then( + (result) => callback(null, result), + (err) => callback(err), + ); + }; + } + + return callbacks; +} diff --git a/packages/shim/src/fs/callback.test.js b/packages/shim/src/fs/callback.test.js new file mode 100644 index 0000000..77a8690 --- /dev/null +++ b/packages/shim/src/fs/callback.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { createFsCallbacks } from "./callback.js"; + +describe("fs callbacks", () => { + it("resolves the promise result through the callback", async () => { + const fakePromises = { readFile: async (p) => `data:${p}` }; + const cb = createFsCallbacks(fakePromises); + + const result = await new Promise((resolve) => + cb.readFile("/x", (err, data) => resolve([err, data])), + ); + + expect(result).toEqual([null, "data:/x"]); + }); + + it("passes a rejection to the callback as the error argument", async () => { + const boom = new Error("nope"); + const fakePromises = { + stat: async () => { + throw boom; + }, + }; + const cb = createFsCallbacks(fakePromises); + + const result = await new Promise((resolve) => + cb.stat("/x", (err) => resolve(err)), + ); + + expect(result).toBe(boom); + }); + + it("forwards the arguments that precede the callback", async () => { + let received = null; + const fakePromises = { + mkdir: async (p, opts) => { + received = [p, opts]; + }, + }; + const cb = createFsCallbacks(fakePromises); + + await new Promise((resolve) => + cb.mkdir("/d", { recursive: true }, () => resolve()), + ); + + expect(received).toEqual(["/d", { recursive: true }]); + }); +}); diff --git a/packages/shim/src/fs/index.js b/packages/shim/src/fs/index.js index fdfaa3e..afd8cbf 100644 --- a/packages/shim/src/fs/index.js +++ b/packages/shim/src/fs/index.js @@ -6,6 +6,8 @@ import { createFsSync } from "./sync.js"; import { createFsWatch } from "./watch.js"; import { createWatcherClient } from "./watcher-client.js"; import { createFdOps } from "./fd.js"; +import { createFsCallbacks } from "./callback.js"; +import { realpath, realpathSync } from "./realpath.js"; import { constants } from "./constants.js"; import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js"; import { wsClient } from "../ws-client.js"; @@ -18,10 +20,13 @@ const fsSync = createFsSync(metadataCache, contentCache, transport); const fsWatch = createFsWatch(transport); const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient); const fdOps = createFdOps(metadataCache, contentCache, transport); +const fsCallbacks = createFsCallbacks(fsPromises); export const fsShim = { promises: fsPromises, + ...fsCallbacks, + existsSync: fsSync.existsSync, readFileSync: fsSync.readFileSync, writeFileSync: fsSync.writeFileSync, @@ -29,6 +34,18 @@ export const fsShim = { accessSync: fsSync.accessSync, statSync: fsSync.statSync, readdirSync: fsSync.readdirSync, + lstatSync: fsSync.lstatSync, + mkdirSync: fsSync.mkdirSync, + rmdirSync: fsSync.rmdirSync, + rmSync: fsSync.rmSync, + renameSync: fsSync.renameSync, + copyFileSync: fsSync.copyFileSync, + appendFileSync: fsSync.appendFileSync, + utimesSync: fsSync.utimesSync, + chmodSync: fsSync.chmodSync, + + realpath, + realpathSync, open: fdOps.open, openSync: fdOps.openSync, diff --git a/packages/shim/src/fs/promises.js b/packages/shim/src/fs/promises.js index 565ecb9..7b059aa 100644 --- a/packages/shim/src/fs/promises.js +++ b/packages/shim/src/fs/promises.js @@ -1,6 +1,10 @@ import { markLocalOp } from "./echo-guard.js"; import { isInputCachePath, inputCacheGet } from "./input-cache.js"; -import { applyReadTransform, applyWriteTransform, resolvePath } from "./transforms.js"; +import { + applyReadTransform, + applyWriteTransform, + resolvePath, +} from "./transforms.js"; import { hasVirtualFile, getVirtualFile } from "./virtual-files.js"; export function createFsPromises(metadataCache, contentCache, transport) { @@ -270,6 +274,10 @@ export function createFsPromises(metadataCache, contentCache, transport) { } }, + async chmod() { + // No permission bits in the vault FS. No-op. + }, + async open(path, flags) { const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null; const resolved = resolvePath(path); diff --git a/packages/shim/src/fs/realpath.js b/packages/shim/src/fs/realpath.js new file mode 100644 index 0000000..17c909a --- /dev/null +++ b/packages/shim/src/fs/realpath.js @@ -0,0 +1,12 @@ +export function realpathSync(path) { + return typeof path === "string" ? path : String(path); +} + +export function realpath(path, options, callback) { + const cb = typeof options === "function" ? options : callback; + + queueMicrotask(() => cb(null, realpathSync(path))); +} + +realpath.native = realpath; +realpathSync.native = realpathSync; diff --git a/packages/shim/src/fs/realpath.test.js b/packages/shim/src/fs/realpath.test.js new file mode 100644 index 0000000..bc3c9eb --- /dev/null +++ b/packages/shim/src/fs/realpath.test.js @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { realpath } from "./realpath.js"; + +describe("fs realpath shim", () => { + it("realpath invokes the callback with the path", async () => { + const result = await new Promise((resolve) => + realpath("/a/b.md", (err, p) => resolve(p)), + ); + + expect(result).toBe("/a/b.md"); + }); + + it("realpath accepts an options argument before the callback", async () => { + const result = await new Promise((resolve) => + realpath("/a/b.md", "utf8", (err, p) => resolve(p)), + ); + + expect(result).toBe("/a/b.md"); + }); +}); diff --git a/packages/shim/src/fs/sync-mutations.test.js b/packages/shim/src/fs/sync-mutations.test.js new file mode 100644 index 0000000..4e0bc9a --- /dev/null +++ b/packages/shim/src/fs/sync-mutations.test.js @@ -0,0 +1,128 @@ +import { describe, it, expect, vi } from "vitest"; +import { createFsSync } from "./sync.js"; +import { resolvePath } from "./transforms.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), + rename: (a, b) => { + if (store.has(a)) { + store.set(b, store.get(a)); + store.delete(a); + } + }, + 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 () => {}), + rm: vi.fn(async () => {}), + rename: vi.fn(async () => {}), + copyFile: vi.fn(async () => {}), + appendFile: vi.fn(async () => {}), + utimes: vi.fn(async () => {}), + stat: vi.fn(async () => ({ type: "file", size: 1 })), + }; + + return { metadataCache, contentCache, transport, store }; +} + +describe("sync fs mutations", () => { + it("lstatSync mirrors statSync", () => { + const deps = makeDeps(); + const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport); + deps.store.set(resolvePath("dir"), { type: "directory" }); + + expect(fs.lstatSync("dir").isDirectory()).toBe(true); + }); + + it("mkdirSync updates the cache and fires the transport", () => { + const deps = makeDeps(); + const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport); + + fs.mkdirSync("newdir", { recursive: true }); + + expect(deps.store.get("newdir")).toEqual({ type: "directory" }); + expect(deps.transport.mkdir).toHaveBeenCalledWith("newdir", true); + }); + + it("rmSync deletes from the cache and fires the transport", () => { + const deps = makeDeps(); + const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport); + const key = resolvePath("gone.md"); + deps.store.set(key, { type: "file" }); + + fs.rmSync("gone.md", { recursive: true }); + + expect(deps.store.has(key)).toBe(false); + expect(deps.transport.rm).toHaveBeenCalled(); + }); + + it("renameSync moves cache metadata and fires the transport", () => { + const deps = makeDeps(); + 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 }); + + fs.renameSync("a.md", "b.md"); + + expect(deps.store.has(from)).toBe(false); + expect(deps.store.get(to)).toEqual({ type: "file", size: 2 }); + expect(deps.transport.rename).toHaveBeenCalled(); + }); + + it("copyFileSync optimistically mirrors source metadata and fires the transport", () => { + const deps = makeDeps(); + 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 }); + + fs.copyFileSync("src.md", "dest.md"); + + expect(deps.store.get(destKey)).toEqual({ type: "file", size: 9 }); + expect(deps.transport.copyFile).toHaveBeenCalled(); + }); + + it("utimesSync sets mtime and fires the transport", () => { + const deps = makeDeps(); + const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport); + const key = resolvePath("note.md"); + deps.store.set(key, { type: "file", mtime: 0 }); + + fs.utimesSync("note.md", 111, 222); + + expect(deps.store.get(key).mtime).toBe(222); + expect(deps.transport.utimes).toHaveBeenCalled(); + }); + + it("chmodSync is a no-op that does not throw", () => { + const deps = makeDeps(); + const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport); + + expect(() => fs.chmodSync("note.md", 0o644)).not.toThrow(); + expect(fs.chmodSync("note.md", 0o644)).toBeUndefined(); + }); +}); diff --git a/packages/shim/src/fs/sync.js b/packages/shim/src/fs/sync.js index 08549d1..f46642a 100644 --- a/packages/shim/src/fs/sync.js +++ b/packages/shim/src/fs/sync.js @@ -180,5 +180,147 @@ export function createFsSync(metadataCache, contentCache, transport) { const entries = metadataCache.readdir(path); return entries.map((e) => e.name); }, + + lstatSync(path) { + // No symlinks in our context. + return this.statSync(path); + }, + + mkdirSync(path, options) { + const recursive = + typeof options === "object" ? !!options.recursive : !!options; + + markLocalOp(path); + metadataCache.set(path, { type: "directory" }); + + transport.mkdir(path, recursive).catch((e) => { + console.error("[shim:fs] mkdirSync background create failed:", path, e); + }); + }, + + rmdirSync(path) { + markLocalOp(path); + metadataCache.delete(path); + + transport.rmdir(path).catch((e) => { + console.error("[shim:fs] rmdirSync background remove failed:", path, e); + }); + }, + + rmSync(path, options) { + const recursive = + typeof options === "object" ? !!options.recursive : false; + + const resolved = resolvePath(path); + + markLocalOp(resolved); + metadataCache.delete(resolved); + contentCache.delete(resolved); + + transport.rm(resolved, recursive).catch((e) => { + console.error( + "[shim:fs] rmSync background remove failed:", + resolved, + e, + ); + }); + }, + + renameSync(oldPath, newPath) { + const resolvedOld = resolvePath(oldPath); + const resolvedNew = resolvePath(newPath); + + markLocalOp(resolvedOld); + markLocalOp(resolvedNew); + const content = contentCache.get(resolvedOld); + + if (content !== null) { + contentCache.set(resolvedNew, content); + contentCache.delete(resolvedOld); + } + + metadataCache.rename(resolvedOld, resolvedNew); + + transport.rename(resolvedOld, resolvedNew).catch((e) => { + console.error( + "[shim:fs] renameSync background rename failed:", + resolvedOld, + e, + ); + }); + }, + + copyFileSync(src, dest) { + const resolvedSrc = resolvePath(src); + const resolvedDest = resolvePath(dest); + + markLocalOp(resolvedDest); + + // Optimistically mirror the source so a sync read right after sees it. + const content = contentCache.get(resolvedSrc); + + if (content !== null) { + contentCache.set(resolvedDest, content); + } + + const srcMeta = metadataCache.get(resolvedSrc); + + if (srcMeta) { + metadataCache.set(resolvedDest, { ...srcMeta }); + } + + transport + .copyFile(src, resolvedDest) + .then(() => transport.stat(resolvedDest)) + .then((meta) => metadataCache.set(resolvedDest, meta)) + .catch((e) => { + console.error( + "[shim:fs] copyFileSync background copy failed:", + resolvedDest, + e, + ); + }); + }, + + appendFileSync(path, data) { + const resolved = resolvePath(path); + + markLocalOp(resolved); + contentCache.invalidate(resolved); + + transport + .appendFile(resolved, data) + .then(() => transport.stat(resolved)) + .then((meta) => metadataCache.set(resolved, meta)) + .catch((e) => { + console.error( + "[shim:fs] appendFileSync background append failed:", + resolved, + e, + ); + }); + }, + + utimesSync(path, atime, mtime) { + const resolved = resolvePath(path); + const meta = metadataCache.get(resolved); + + if (meta) { + meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime(); + metadataCache.set(resolved, meta); + } + + transport.utimes(resolved, atime, mtime).catch((e) => { + console.error( + "[shim:fs] utimesSync background utimes failed:", + resolved, + e, + ); + }); + }, + + chmodSync() { + // The vault FS does not model permission bits. No-op. + }, }; } diff --git a/packages/shim/src/globals.js b/packages/shim/src/globals.js index 7a74b80..bd837c5 100644 --- a/packages/shim/src/globals.js +++ b/packages/shim/src/globals.js @@ -228,7 +228,12 @@ function installContextMenuFix() { ); } +function installGlobalAlias() { + window.global = window; +} + export function installGlobals() { + installGlobalAlias(); installProcess(); installBuffer(); installFetchShim(); diff --git a/packages/shim/src/node/assert.js b/packages/shim/src/node/assert.js new file mode 100644 index 0000000..8f6553a --- /dev/null +++ b/packages/shim/src/node/assert.js @@ -0,0 +1,82 @@ +class AssertionError extends Error { + constructor(message) { + super(message || "Assertion failed"); + this.name = "AssertionError"; + } +} + +function assert(value, message) { + if (!value) { + throw new AssertionError(message); + } +} + +assert.AssertionError = AssertionError; +assert.ok = assert; +assert.strict = assert; + +assert.fail = function (message) { + throw new AssertionError(message || "Failed"); +}; + +assert.equal = function (actual, expected, message) { + if (actual != expected) { + throw new AssertionError(message || `${actual} == ${expected}`); + } +}; + +assert.notEqual = function (actual, expected, message) { + if (actual == expected) { + throw new AssertionError(message || `${actual} != ${expected}`); + } +}; + +assert.strictEqual = function (actual, expected, message) { + if (actual !== expected) { + throw new AssertionError(message || `${actual} === ${expected}`); + } +}; + +assert.notStrictEqual = function (actual, expected, message) { + if (actual === expected) { + throw new AssertionError(message || `${actual} !== ${expected}`); + } +}; + +assert.deepEqual = function (actual, expected, message) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new AssertionError(message || "deepEqual"); + } +}; + +assert.deepStrictEqual = assert.deepEqual; + +assert.throws = function (fn, message) { + let threw = false; + + try { + fn(); + } catch { + threw = true; + } + + if (!threw) { + throw new AssertionError(message || "Missing expected exception"); + } +}; + +assert.doesNotThrow = function (fn, message) { + try { + fn(); + } catch (e) { + throw new AssertionError(message || `Got unwanted exception: ${e.message}`); + } +}; + +assert.ifError = function (value) { + if (value) { + throw new AssertionError(`ifError got unwanted exception: ${value}`); + } +}; + +export const assertShim = assert; diff --git a/packages/shim/src/node/assert.test.js b/packages/shim/src/node/assert.test.js new file mode 100644 index 0000000..b10916d --- /dev/null +++ b/packages/shim/src/node/assert.test.js @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { assertShim as assert } from "./assert.js"; + +describe("assert shim", () => { + it("is callable and throws on a falsy value", () => { + expect(() => assert(false)).toThrow(); + expect(() => assert(true)).not.toThrow(); + }); + + it("equal throws on mismatch and passes on loose match", () => { + expect(() => assert.equal(1, 2)).toThrow(); + expect(() => assert.equal(1, 1)).not.toThrow(); + expect(() => assert.equal(1, "1")).not.toThrow(); + }); + + it("strictEqual distinguishes type", () => { + expect(() => assert.strictEqual(1, "1")).toThrow(); + expect(() => assert.strictEqual(1, 1)).not.toThrow(); + }); + + it("throws() verifies that a function threw", () => { + expect(() => + assert.throws(() => { + throw new Error("x"); + }), + ).not.toThrow(); + + expect(() => assert.throws(() => {})).toThrow(); + }); +}); diff --git a/packages/shim/src/node/constants.js b/packages/shim/src/node/constants.js new file mode 100644 index 0000000..9d6c637 --- /dev/null +++ b/packages/shim/src/node/constants.js @@ -0,0 +1,50 @@ +// Linux constant values, to match the platform the process shim reports. +// O_SYMLINK and other macOS/BSD flags are omitted so feature checks treat platform as linux + +export const constantsShim = { + // File access checks (fs.access mode). + F_OK: 0, + X_OK: 1, + W_OK: 2, + R_OK: 4, + + // open() flags. + O_RDONLY: 0, + O_WRONLY: 1, + O_RDWR: 2, + O_CREAT: 64, + O_EXCL: 128, + O_NOCTTY: 256, + O_TRUNC: 512, + O_APPEND: 1024, + O_DIRECTORY: 65536, + O_NOATIME: 262144, + O_NOFOLLOW: 131072, + O_SYNC: 1052672, + O_DSYNC: 4096, + O_NONBLOCK: 2048, + + // File type bits (st_mode & S_IFMT). + S_IFMT: 61440, + S_IFREG: 32768, + S_IFDIR: 16384, + S_IFCHR: 8192, + S_IFBLK: 24576, + S_IFIFO: 4096, + S_IFLNK: 40960, + S_IFSOCK: 49152, + + // Permission bits. + S_IRWXU: 448, + S_IRUSR: 256, + S_IWUSR: 128, + S_IXUSR: 64, + S_IRWXG: 56, + S_IRGRP: 32, + S_IWGRP: 16, + S_IXGRP: 8, + S_IRWXO: 7, + S_IROTH: 4, + S_IWOTH: 2, + S_IXOTH: 1, +}; diff --git a/packages/shim/src/node/stream.js b/packages/shim/src/node/stream.js new file mode 100644 index 0000000..91e1aa1 --- /dev/null +++ b/packages/shim/src/node/stream.js @@ -0,0 +1,85 @@ +import { EventEmitter } from "./events.js"; + +let warned = false; + +function warnNoDataFlow(method) { + if (warned) { + return; + } + + warned = true; + console.warn( + `[shim:stream] ${method}() called, but stream data flow is not implemented. ` + + "This plugin needs the full stream shim.", + ); +} + +export class Stream extends EventEmitter { + pipe(destination) { + warnNoDataFlow("pipe"); + return destination; + } +} + +export class Readable extends Stream { + constructor(options) { + super(); + this.readable = true; + this._readableState = { options: options || {} }; + } + + read() { + warnNoDataFlow("read"); + return null; + } + + push() { + warnNoDataFlow("push"); + return false; + } + + _read() {} +} + +export class Writable extends Stream { + constructor(options) { + super(); + this.writable = true; + this._writableState = { options: options || {} }; + } + + write() { + warnNoDataFlow("write"); + return false; + } + + end() { + warnNoDataFlow("end"); + return this; + } + + _write() {} +} + +export class Duplex extends Readable { + constructor(options) { + super(options); + this.writable = true; + } + + write() { + warnNoDataFlow("write"); + return false; + } + + end() { + warnNoDataFlow("end"); + return this; + } +} + +export class Transform extends Duplex { + _transform() {} +} + +export class PassThrough extends Transform {} diff --git a/packages/shim/src/node/stream.test.js b/packages/shim/src/node/stream.test.js new file mode 100644 index 0000000..4708c16 --- /dev/null +++ b/packages/shim/src/node/stream.test.js @@ -0,0 +1,16 @@ +import { describe, it, expect, vi } from "vitest"; +import { Readable, Writable } from "./stream.js"; + +describe("stream shim", () => { + it("warns once when data-flow methods are used", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + new Readable().read(); + new Writable().write("x"); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("[shim:stream]"); + + warn.mockRestore(); + }); +}); diff --git a/packages/shim/src/process.js b/packages/shim/src/process.js index 1a93b79..fe6aa3c 100644 --- a/packages/shim/src/process.js +++ b/packages/shim/src/process.js @@ -1,5 +1,6 @@ export const processShim = { platform: "linux", + version: "v18.18.0", versions: { electron: "28.2.3", node: "18.18.0", diff --git a/packages/shim/src/require.js b/packages/shim/src/require.js index 1444460..3c33c21 100644 --- a/packages/shim/src/require.js +++ b/packages/shim/src/require.js @@ -11,6 +11,9 @@ import * as netShim from "./node/net.js"; import * as httpShim from "./node/http.js"; import * as zlibShim from "./node/zlib.js"; import * as utilShim from "./node/util.js"; +import { constantsShim } from "./node/constants.js"; +import { assertShim } from "./node/assert.js"; +import * as streamShim from "./node/stream.js"; import { wrapWithProxy, installDebugHelpers } from "./debug.js"; const rawRegistry = { @@ -29,6 +32,9 @@ const rawRegistry = { https: httpShim, zlib: zlibShim, util: utilShim, + constants: constantsShim, + assert: assertShim, + stream: streamShim, }; const shimRegistry = {};