From bd7fb43819e60267e836399134041f93fd5239da Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Sun, 28 Jul 2024 14:22:21 -0700 Subject: [PATCH] feat: make it way faster --- README.md | 47 ++- eslint.config.js | 9 + package-lock.json | 459 ++++++++++++++++++++++++++++- package.json | 27 +- src/impvol-hooks.ts | 173 +++++------ src/impvol.ts | 211 ++++--------- src/index.ts | 2 +- src/paths-cjs.cts | 5 + src/{resolve-hooks.ts => paths.ts} | 4 +- src/resolve-hooks-cjs.cts | 1 - src/{impvol-event.ts => types.ts} | 4 +- test/impvol.spec.ts | 55 ++++ tsconfig.json | 3 +- 13 files changed, 734 insertions(+), 266 deletions(-) create mode 100644 src/paths-cjs.cts rename src/{resolve-hooks.ts => paths.ts} (60%) delete mode 100644 src/resolve-hooks-cjs.cts rename src/{impvol-event.ts => types.ts} (92%) create mode 100644 test/impvol.spec.ts diff --git a/README.md b/README.md index 15ae634..3ffc978 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,48 @@ # impvol -> Import scripts and modules from virtual filesystems! +> Import scripts and modules from virtual filesystems with [memfs](https://npm.im/memfs)! + +## Usage + +```ts +import {memfs} from 'memfs'; +import {impvol} from 'impvol'; + +const {vol} = memfs({ + '/foo.mjs': 'export const foo = 42;', + '/bar.cjs': 'exports.bar = 42;', +}); + +// ivol is a new Volume +const ivol = impvol(vol); + +await ivol.promises.writeFile('/baz.mjs', 'export const baz = 42;'); + +const foo = await import('/foo.mjs'); // {foo: 42} +const bar = await import('/bar.cjs'); // {bar: 42} +const baz = await import('/baz.mjs'); // {baz: 42} +``` + +This should be a _drop-in replacement_ for a `memfs` `Volume`, but it isn't quite there. + +> [!CAUTION] +> +> - This lib monkeypatches `memfs` internals. _I'm_ not really comfortable with that, and you shouldn't be either. +> - Does not yet support JSON. +> - WASM support is unknown. +> - Interaction with other loaders is unknown. + +## Requirements + +- Node.js v20.0.0+ +- `memfs` v4.0.0+ + +## Installation + +```sh +npm install impvol memfs -D +``` + +## License + +©️ 2024 Christopher "boneskull" Hiller. Licensed Apache-2.0 diff --git a/eslint.config.js b/eslint.config.js index e4ebcb7..140d189 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -33,6 +33,9 @@ export default tseslint.config( 'n/no-unpublished-import': 'off', + // seems to be incompatible with tshy + 'n/no-extraneous-import': 'off', + // I like my template expressions, tyvm '@typescript-eslint/restrict-template-expressions': 'off', @@ -127,4 +130,10 @@ export default tseslint.config( 'n/no-missing-import': ['error', {allowModules: ['impvol']}], }, }, + { + files: ['test/**/*.ts'], + rules: { + '@typescript-eslint/no-floating-promises': 'off', + }, + }, ); diff --git a/package-lock.json b/package-lock.json index e2a72cf..0bbf3ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { - "debug": "^4.3.5" + "debug": "^4.3.6" }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.3.0", @@ -44,6 +44,7 @@ "prettier-plugin-sh": "^0.14.0", "sentences-per-line": "^0.2.1", "tshy": "^3.0.2", + "tsx": "^4.16.2", "typescript": "^5.5.4", "typescript-eslint": "^7.17.0" }, @@ -825,6 +826,397 @@ "node": ">=16" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@eslint-community/eslint-plugin-eslint-comments": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.3.0.tgz", @@ -2321,9 +2713,9 @@ } }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "license": "MIT", "dependencies": { "ms": "2.1.2" @@ -2514,6 +2906,45 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -6902,6 +7333,26 @@ "dev": true, "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.2.tgz", + "integrity": "sha512-C1uWweJDgdtX2x600HjaFaucXTilT7tgUZHbOE4+ypskZ1OP8CRCSDkCxG6Vya9EwaFIVagWwpaVAn5wzypaqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.21.5", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 068a580..1e2d589 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "impvol", - "version": "0.0.0", + "version": "0.0.1", "type": "module", - "description": "stuff", + "description": "Import scripts and modules from virtual filesystems", "repository": { "type": "git", "url": "https://github.com/boneskull/impvol" @@ -13,7 +13,7 @@ }, "license": "Apache-2.0", "engines": { - "node": ">=20" + "node": ">=20.0.0" }, "main": "./dist/commonjs/index.js", "module": "./dist/esm/index.js", @@ -38,6 +38,20 @@ "src", "README.md" ], + "keywords": [ + "memfs", + "vfs", + "fs", + "virtual", + "import", + "require", + "loader", + "nodejs", + "script", + "cjs", + "esm", + "module" + ], "scripts": { "build": "tshy", "format": "prettier .", @@ -46,10 +60,14 @@ "lint:md": "markdownlint-cli2 \"**/*.md\" \".github/**/*.md\"", "lint:spelling": "cspell \"**\" \".github/**/*\"", "prepare": "husky", + "test": "node --import tsx ./test/impvol.spec.ts", "tsc": "tsc -p tsconfig.tsc.json" }, + "peerDependencies": { + "memfs": "^4.0.0" + }, "dependencies": { - "debug": "^4.3.5" + "debug": "^4.3.6" }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.3.0", @@ -84,6 +102,7 @@ "prettier-plugin-sh": "^0.14.0", "sentences-per-line": "^0.2.1", "tshy": "^3.0.2", + "tsx": "^4.16.2", "typescript": "^5.5.4", "typescript-eslint": "^7.17.0" }, diff --git a/src/impvol-hooks.ts b/src/impvol-hooks.ts index 39acd60..d30bfcd 100644 --- a/src/impvol-hooks.ts +++ b/src/impvol-hooks.ts @@ -1,31 +1,22 @@ +/* eslint-disable n/no-missing-import */ /** * Implements custom hooks for importing from virtual file systems * * @packageDocumentation - * @todo Probably nix the entire idea of multiple volumes and just use a - * singleton. I cannot see how to reconcile multiple volumes within Node.js' - * module system short of using import attributes or search params or - * something. */ - import Debug from 'debug'; -// eslint-disable-next-line n/no-missing-import -import {fromJsonSnapshotSync} from 'memfs/lib/snapshot/json.js'; -// eslint-disable-next-line n/no-missing-import +import {fromBinarySnapshotSync} from 'memfs/lib/snapshot/binary.js'; import {Volume} from 'memfs/lib/volume.js'; +import {readFileSync} from 'node:fs'; import { type InitializeHook, type LoadHook, + type ModuleFormat, + type ModuleSource, type ResolveHook, } from 'node:module'; import path from 'node:path'; -import {inspect} from 'node:util'; -import {type MessagePort} from 'node:worker_threads'; -import { - type ImpVolAckEvent, - type ImpVolEvent, - type ImpVolInitData, -} from './impvol-event.js'; +import {type ImpVolInitData} from './types.js'; const debug = Debug('impvol:hooks'); @@ -33,68 +24,70 @@ const PROTOCOL = 'impvol'; let vol: Volume | undefined; -const knownPaths: Set = new Set(); - +/** + * Gets or sets & gets the {@link vol} singleton + * + * @returns The {@link vol} singleton + */ function getVolume(): Volume { return vol ?? (vol = new Volume()); } -function updateVolumeMap() { - for (const filepath of Object.keys(getVolume().toJSON())) { - knownPaths.add(filepath); - knownPaths.add(path.dirname(filepath)); +let tmp: string; +let buf: Uint8Array; + +export const initialize: InitializeHook = ({ + tmp: _tmp, + sab, +}) => { + tmp = _tmp; + buf = new Uint8Array(sab); +}; + +/** + * @todo Test WASM + */ +const DISALLOWED_FORMATS = new Set(['builtin']); + +function guessFormat(specifier: string): ModuleFormat | undefined { + const ext = path.extname(specifier); + switch (ext) { + case '.mjs': + return 'module'; + case '.cjs': + return 'commonjs'; + case '.json': + return 'json'; + case '.wasm': + return 'wasm'; + default: + return undefined; } } -function ack(port: MessagePort, event: ImpVolEvent): void { - const ackEvent: ImpVolAckEvent = { - type: 'ACK', - id: event.id, - impVolId: event.impVolId, - }; - port.postMessage(ackEvent); +function shouldReload(): boolean { + return Atomics.load(buf, 0) !== 0; } -export const initialize: InitializeHook = ({port}) => { - port.on('message', (event: ImpVolEvent) => { - const start = performance.now(); - const vol = getVolume(); - switch (event.type) { - case 'UPDATE': { - fromJsonSnapshotSync(event.json, {fs: vol}); - updateVolumeMap(); - break; - } - case 'CLEAR': { - knownPaths.clear(); - vol.reset(); - break; - } - default: { - throw new TypeError(`Unknown message: ${inspect(event)}`); - } - } - - ack(port, event); - - debug( - 'Event %s handled in %sms', - event.type, - (performance.now() - start).toFixed(2), - ); - }); -}; - -const DISALLOWED_FORMATS = new Set(['wasm', 'builtin']); +function reload() { + const buffer = readFileSync(tmp); + const cbor = new Uint8Array(buffer); + const vol = getVolume(); + vol.reset(); + // XXX: memfs needs to re-export type CborUint8Array + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + fromBinarySnapshotSync(cbor as any, {fs: vol}); +} export const resolve: ResolveHook = (specifier, context, nextResolve) => { const start = performance.now(); - if (knownPaths.has(specifier)) { + if (shouldReload()) { + reload(); + } + if (getVolume().existsSync(specifier)) { try { - const ext = path.extname(specifier); - const format = - ext === '.mjs' ? 'module' : ext === '.json' ? 'json' : 'commonjs'; - const url = new URL(`${PROTOCOL}://${specifier}`).href; + const format = guessFormat(specifier); + const {href: url} = new URL(`${PROTOCOL}://${specifier}`); debug( 'Resolved specifier: %s ➡️ %s (%sms)', specifier, @@ -116,16 +109,11 @@ export const resolve: ResolveHook = (specifier, context, nextResolve) => { export const load: LoadHook = (specifier, context, nextLoad) => { const start = performance.now(); - let url: URL; - try { - url = new URL(specifier); - } catch (err) { - debug('Error parsing "%s" as URL: %s', specifier, err); - return nextLoad(specifier, context); + if (shouldReload()) { + reload(); } - if (url.protocol.startsWith(PROTOCOL)) { + if (specifier.startsWith(`${PROTOCOL}://`)) { const {format} = context; - if (DISALLOWED_FORMATS.has(format)) { debug( 'Warning: %s with unsupported format %s tried to load via VFS', @@ -134,30 +122,27 @@ export const load: LoadHook = (specifier, context, nextLoad) => { ); return nextLoad(specifier, context); } - const filepath = url.pathname; - if (!knownPaths.has(filepath)) { - debug('Warning: %s not found in VFS', filepath); - return nextLoad(specifier, context); + let source: ModuleSource; + let pathname: string; + try { + // JIT URL parsing + ({pathname} = new URL(specifier)); + source = getVolume().readFileSync(pathname); + } catch (err) { + debug(err); + throw err; } - return getVolume() - .promises.readFile(filepath) - .then((source) => { - if (!knownPaths.has(filepath)) { - debug('Warning: %s not found in VFS', filepath); - return nextLoad(specifier, context); - } - debug( - 'Loaded %s from VFS (%sms)', - filepath, - (performance.now() - start).toFixed(2), - ); - return { - format, - shortCircuit: true, - source, - }; - }); + debug( + 'Loaded %s from VFS (%sms)', + pathname, + (performance.now() - start).toFixed(2), + ); + return { + format, + shortCircuit: true, + source, + }; } return nextLoad(specifier, context); }; diff --git a/src/impvol.ts b/src/impvol.ts index fa80bb5..e5c0233 100644 --- a/src/impvol.ts +++ b/src/impvol.ts @@ -1,169 +1,69 @@ +/* eslint-disable n/no-unsupported-features/node-builtins */ +/* eslint-disable n/no-missing-import */ /** * Provides the {@link ImportableVolume} class, which synchronizes a virtual * filesystem with a worker thread running a custom import hook. * * @packageDocumentation */ - import Debug from 'debug'; -// eslint-disable-next-line n/no-missing-import -import {type Link, type Node} from 'memfs/lib/node.js'; -// eslint-disable-next-line n/no-missing-import -import {toJsonSnapshotSync} from 'memfs/lib/snapshot/json.js'; -// eslint-disable-next-line n/no-missing-import +import { + fromBinarySnapshotSync, + toBinarySnapshotSync, +} from 'memfs/lib/snapshot/binary.js'; import {Volume, type DirectoryJSON} from 'memfs/lib/volume.js'; -import {once} from 'node:events'; -// eslint-disable-next-line n/no-unsupported-features/node-builtins +import {mkdtempSync, writeFileSync} from 'node:fs'; import {register} from 'node:module'; -import {pathToFileURL} from 'node:url'; -import {MessageChannel, type MessagePort} from 'node:worker_threads'; -import { - type ImpVolClearEvent, - type ImpVolEvent, - type ImpVolHookEvent, - type ImpVolId, - type ImpVolInitData, - type ImpVolUpdateEvent, -} from './impvol-event.js'; -import {RESOLVE_HOOKS_PATH} from './resolve-hooks.js'; - -type ImpVolQueueItem = { - event: ImpVolEvent; - resolve: () => void; - reject: (err: unknown) => void; -}; - -function createId(prefix = 'impvol'): ImpVolId { - return `${prefix}-${Math.random().toString(36).slice(7)}` as ImpVolId; -} - -export class ImportableVolume extends Volume { - readonly #impVolId = createId(); - - readonly #queue: ImpVolQueueItem[] = []; - - #pending: boolean = false; +import path from 'node:path'; +import {tmpdir} from 'os'; +import {DEFAULT_HOOKS_PATH} from './paths-cjs.cjs'; +import {IMPVOL_URL} from './paths.js'; +import {type ImpVolInitData} from './types.js'; - constructor( - private readonly port: MessagePort, - props: {Node?: Node; Link?: Link; File?: File} = {}, - ) { - super(props); - } +let impVol: ImportableVolume; - public static async registerHook( - this: void, - volume?: Volume | string, - hooksPath: string = RESOLVE_HOOKS_PATH, - ): Promise { - await Promise.resolve(); - if (typeof volume === 'string') { - hooksPath = volume; - volume = undefined; +export class ImportableVolume extends Volume { + public static registerHook(this: void, volume?: Volume): ImportableVolume { + if (impVol) { + return impVol; } + registerLoaderHook(); - const port = registerLoaderHook(hooksPath); + impVol = new ImportableVolume(); - const impVol = new ImportableVolume(port); + // clone the volume if it is non-empty if (volume) { - await impVol.__update__(); + if (Object.keys(volume.toJSON()).length) { + debug('Cloning volume'); + const snapshot = toBinarySnapshotSync({fs: volume}); + fromBinarySnapshotSync(snapshot, {fs: impVol}); + impVol.__update__(); + } else { + debug('Refusing to clone empty volume'); + } } + return impVol; } /** - * Updates the worker with a snapshot of the current virtual filesystem - * - * @todo This method should be Private private. But if it was, the prototype - * extension below wouldn't work. But if the prototype extension was stuffed - * into this class proper, then TS would complain in a way which is not so - * easily ignored. Maybe use a `Proxy` or something instead. + * @internal */ - async __update__(): Promise { - const json = toJsonSnapshotSync({fs: this}); - const event: ImpVolUpdateEvent = { - type: 'UPDATE', - json, - id: createId('update'), - impVolId: this.#impVolId, - }; - await this.#enqueue(event); + public __update__() { + const snapshot = toBinarySnapshotSync({fs: this}); + writeFileSync(tmp, snapshot); + Atomics.store(uint8, 0, 1); + debug('Updated snapshot'); } - public override async fromJSON( - json: DirectoryJSON, - cwd?: string, - ): Promise { + public override fromJSON(json: DirectoryJSON, cwd?: string): void { super.fromJSON(json, cwd); - await this.__update__(); + this.__update__(); } - public override async reset(): Promise { - const id = createId('clear'); - const event: ImpVolClearEvent = { - type: 'CLEAR', - id, - impVolId: this.#impVolId, - }; - await this.#enqueue(event); + public override reset(): void { super.reset(); - } - - /** - * Async queue - */ - async #dequeue(): Promise { - if (this.#pending) { - return; - } - const item = this.#queue.shift(); - if (!item) { - return; - } - const {resolve, reject, event} = item; - try { - this.#pending = true; - this.port.postMessage(event); - await this.#waitForAck(event.id); - this.#pending = false; - resolve(); - } catch (err) { - this.#pending = false; - reject(err); - } finally { - void this.#dequeue(); - } - } - - #enqueue(event: ImpVolEvent): Promise { - return new Promise((resolve, reject) => { - this.#queue.push({event, resolve, reject}); - void this.#dequeue(); - }); - } - - async #waitFor( - type: T['type'], - id?: ImpVolId, - ): Promise { - const [message] = (await once(this.port, 'message')) as [ImpVolHookEvent]; - if ( - message.type === type && - message.impVolId === this.#impVolId && - (!id || message.id === id) - ) { - return message as T; - } - debug('Waiting for event "%s"; received: %O', type, message); - return this.#waitFor(type, id); - } - - /** - * @param id Event ID to wait for - * @todo Timeout? - */ - async #waitForAck(id: ImpVolId): Promise { - await this.#waitFor('ACK', id); + this.__update__(); } } // overrides private methods such that meaningful filesystem writes trigger an @@ -217,33 +117,32 @@ Object.assign(ImportableVolume.prototype, { }, }); +let sab: SharedArrayBuffer; +let tmp: string; +let uint8: Uint8Array; + /** * Registers the loader hook * * @param hooksPath Absolute path to hooks file * @returns A new MessagePort for communication with the hooks worker */ -function registerLoaderHook(hooksPath: string): MessagePort { - if (registerLoaderHook.cache.has(hooksPath)) { - return registerLoaderHook.cache.get(hooksPath)!; - } - const {port1: port, port2: hookPort} = new MessageChannel(); - - register(hooksPath, { - parentURL, +function registerLoaderHook() { + const tmpDir = mkdtempSync(`${tmpdir()}/impvol-`); + tmp = path.join(tmpDir, 'impvol.cbor'); + debug('Created temp file at %s', tmp); + sab = new SharedArrayBuffer(1); + uint8 = new Uint8Array(sab); + Atomics.store(uint8, 0, 0); + register(DEFAULT_HOOKS_PATH, { + parentURL: IMPVOL_URL, data: { - port: hookPort, + tmp, + sab, }, - transferList: [hookPort], }); - - registerLoaderHook.cache.set(hooksPath, port); - - return port; } -registerLoaderHook.cache = new Map(); const debug = Debug('impvol'); -export const createImportableVolume = ImportableVolume.registerHook; -const parentURL = pathToFileURL(__filename).href; +export const impvol = ImportableVolume.registerHook; diff --git a/src/index.ts b/src/index.ts index 8dac070..85698bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ export * from './impvol.js'; -export * from './impvol-event.js'; +export * from './types.js'; diff --git a/src/paths-cjs.cts b/src/paths-cjs.cts new file mode 100644 index 0000000..6d01f29 --- /dev/null +++ b/src/paths-cjs.cts @@ -0,0 +1,5 @@ +import {pathToFileURL} from 'url'; + +export const DEFAULT_HOOKS_PATH = require.resolve('./impvol-hooks.js'); + +export const IMPVOL_URL = pathToFileURL(require.resolve('./impvol.js')); diff --git a/src/resolve-hooks.ts b/src/paths.ts similarity index 60% rename from src/resolve-hooks.ts rename to src/paths.ts index 503736c..5978a1d 100644 --- a/src/resolve-hooks.ts +++ b/src/paths.ts @@ -1,7 +1,7 @@ import {fileURLToPath} from 'url'; export const RESOLVE_HOOKS_PATH = fileURLToPath( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore for tshy new URL('./impvol-hooks.js', import.meta.url), ); + +export const IMPVOL_URL = new URL('./impvol.js', import.meta.url).href; diff --git a/src/resolve-hooks-cjs.cts b/src/resolve-hooks-cjs.cts deleted file mode 100644 index 0af9436..0000000 --- a/src/resolve-hooks-cjs.cts +++ /dev/null @@ -1 +0,0 @@ -export const DEFAULT_HOOKS_PATH = require.resolve('./impvol-hooks.js'); diff --git a/src/impvol-event.ts b/src/types.ts similarity index 92% rename from src/impvol-event.ts rename to src/types.ts index e1663be..29a856f 100644 --- a/src/impvol-event.ts +++ b/src/types.ts @@ -2,7 +2,6 @@ import {type SnapshotNode} from 'memfs/lib/snapshot/types.js'; // eslint-disable-next-line n/no-missing-import import {type JsonUint8Array} from 'memfs/lib/snapshot/json.js'; -import type {MessagePort} from 'node:worker_threads'; declare const tag: unique symbol; @@ -34,5 +33,6 @@ export type ImpVolHookEvent = ImpVolAckEvent; export type ImpVolEvent = ImpVolUpdateEvent | ImpVolClearEvent; export type ImpVolInitData = { - port: MessagePort; + tmp: string; + sab: SharedArrayBuffer; }; diff --git a/test/impvol.spec.ts b/test/impvol.spec.ts new file mode 100644 index 0000000..4e09f96 --- /dev/null +++ b/test/impvol.spec.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert'; +import {describe, it} from 'node:test'; +import {ImportableVolume, impvol} from '../src/impvol.js'; +// eslint-disable-next-line n/no-missing-import +import {Volume} from 'memfs/lib/volume.js'; + +const TEST_DIR = '/__impvol__'; + +describe('impvol', () => { + describe('ImportableVolume', () => {}); + + describe('createImportableVolume()', () => { + it('should return a ImportableVolume instance', () => { + const vol = impvol(); + assert.ok(vol instanceof ImportableVolume); + assert.ok(vol instanceof Volume); + }); + + it('should import a script', async () => { + const content = 'module.exports = "Hello, world!";'; + + const vol = impvol(); + vol.fromJSON( + { + 'test.cjs': content, + }, + TEST_DIR, + ); + + const result = (await import(`${TEST_DIR}/test.cjs`)) as { + default: string; + }; + + assert.strictEqual(result.default, 'Hello, world!'); + }); + + it('should import a module', async () => { + const content = 'export default "Hello, world!";'; + + const vol = impvol(); + vol.fromJSON( + { + 'test.mjs': content, + }, + TEST_DIR, + ); + + const result = (await import(`${TEST_DIR}/test.mjs`)) as { + default: string; + }; + + assert.strictEqual(result.default, 'Hello, world!'); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index ebafe60..f4de315 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "target": "es2022" + "target": "es2022", + "types": ["node"] } }