diff --git a/build/registerVue.js b/build/registerVue.js index eb8dc4736..6db7d017c 100644 --- a/build/registerVue.js +++ b/build/registerVue.js @@ -1,5 +1,8 @@ -import { register } from 'node:module'; +import { registerHooks } from 'node:module'; +import { load } from './vueLoader.js'; // Lets Node import single-file components: `node --import=`. -// Hosts reach it as `/build/register-vue`. -register('./vueLoader.js', import.meta.url); +// Hosts reach it as `/build/register-vue`. The hooks run on the +// loading thread: off-thread hooks (module.register) would add a round trip +// to every module a test imports, not only the components. +registerHooks({ load }); diff --git a/build/vueLoader.js b/build/vueLoader.js index a549fca47..13026dc04 100644 --- a/build/vueLoader.js +++ b/build/vueLoader.js @@ -1,14 +1,32 @@ -import { readFile } from 'node:fs/promises'; +import { createHash, randomBytes } from 'node:crypto'; +import { + lstatSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { compileScript, compileTemplate, parse } from 'vue/compiler-sfc'; import { VUE_TEMPLATE_OPTIONS } from './vueOptions.js'; +const require = createRequire(import.meta.url); + +// The compiler loads on the first component that is not cached, so a test +// process that imports no views, or only cached ones, never pays for it. +let compiler = null; +const sfcCompiler = () => (compiler ??= require('vue/compiler-sfc')); + /** * Compile a single-file component to an ES module for Node, so unit tests (the * application's and a host's) can import components without a bundler. * Styles are dropped: tests read the global stylesheet separately. */ export function compileVueModule(source, filename) { + const { compileScript, compileTemplate, parse } = sfcCompiler(); // The template is parsed with the SFC; parse it as the browser build does. const { descriptor, errors } = parse(source, { filename, @@ -40,14 +58,113 @@ export function compileVueModule(source, filename) { return `${script}\n${template}\nif (typeof render === 'function') __sfc__.render = render;\nexport default __sfc__;\n`; } -/** Node module hook: load `.vue` files as compiled modules. */ -export async function load(url, context, nextLoad) { +// The loader's own code is part of every key, so a change to how components +// compile never reuses output from before it. +let loaderFingerprint = null; +const fingerprint = () => + (loaderFingerprint ??= createHash('sha256') + .update(readFileSync(fileURLToPath(import.meta.url))) + .update(readFileSync(new URL('./vueOptions.js', import.meta.url))) + .update(require('vue/package.json').version) + .digest('hex')); + +/** + * Where compiled components are kept between test processes, never a + * package's own tree (a host's checks refuse files inside Core): the system + * temp directory, or on Windows the per-user local app data directory, since + * a temp directory there may be shared and its ownership is not checked. + * GEV_VUE_CACHE=0 compiles every time. + */ +export function vueCacheDirectory( + env = process.env, + platform = process.platform, +) { + if (env.GEV_VUE_CACHE === '0') return null; + if (platform === 'win32') { + return env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'gev-vue-sfc') : null; + } + return path.join(tmpdir(), `gev-vue-sfc-${process.getuid()}`); +} + +/** The cache entry for one component: its path, its text and the loader. */ +export function vueCacheKey(source, filename) { + return createHash('sha256') + .update(JSON.stringify([fingerprint(), filename])) + .update('\0') + .update(source) + .digest('hex'); +} + +/** + * Compile a component, reusing a previous process's result for the same text. + * A cache that cannot be read or written only costs the compile. + */ +export function cachedVueModule( + source, + filename, + directory = vueCacheDirectory(), +) { + if (!directory || !ownCacheDirectory(directory)) { + return compileVueModule(source, filename); + } + const entry = path.join(directory, `${vueCacheKey(source, filename)}.mjs`); + try { + return readFileSync(entry, 'utf8'); + } catch { + // Not compiled yet. + } + const compiled = compileVueModule(source, filename); + // Concurrent test processes and threads may compile the same component; + // each writes its own new file and the rename makes the entry appear whole. + const partial = `${entry}.${randomBytes(6).toString('hex')}.tmp`; + try { + writeFileSync(partial, compiled, { flag: 'wx', mode: 0o600 }); + renameSync(partial, entry); + } catch { + // An unwritable directory, or a rename another process holds open, + // leaves the cache without this entry. + try { + rmSync(partial, { force: true }); + } catch { + // A partial file that cannot be removed is only clutter. + } + } + return compiled; +} + +/** + * Cached modules run as code, so the directory must be this user's own: a + * real directory (not a link), owned by the current user and writable by no + * one else. In a shared temp directory another user could otherwise create it + * first and plant modules. On Windows it sits in the user's own profile. + */ +const checkedDirectories = new Map(); +export function ownCacheDirectory(directory) { + if (!checkedDirectories.has(directory)) { + checkedDirectories.set(directory, isOwnDirectory(directory)); + } + return checkedDirectories.get(directory); +} + +function isOwnDirectory(directory) { + try { + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const stats = lstatSync(directory); + if (!stats.isDirectory()) return false; + if (process.platform === 'win32') return true; + return stats.uid === process.getuid() && (stats.mode & 0o022) === 0; + } catch { + return false; + } +} + +/** Synchronous module hook (module.registerHooks): `.vue` loads compiled. */ +export function load(url, context, nextLoad) { if (!url.endsWith('.vue')) return nextLoad(url, context); const filename = fileURLToPath(url); - const source = await readFile(filename, 'utf8'); return { format: 'module', - source: compileVueModule(source, filename), + source: cachedVueModule(readFileSync(filename, 'utf8'), filename), shortCircuit: true, }; } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c6ae4b493..240b52e12 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -68,6 +68,7 @@ "server/standalone/vite.config.js", "vite.config.js", "src/tooling/viteBuild.test.mjs", + "src/tooling/vueLoader.test.mjs", "src/tooling/allowedHosts.test.mjs", "server/providers/common/http.js", "server/providers/common/query.js", diff --git a/src/tooling/vueLoader.test.mjs b/src/tooling/vueLoader.test.mjs new file mode 100644 index 000000000..772299c0b --- /dev/null +++ b/src/tooling/vueLoader.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + cachedVueModule, + compileVueModule, + ownCacheDirectory, + vueCacheDirectory, + vueCacheKey, +} from '../../build/vueLoader.js'; + +const SOURCE = + '\n\n'; +const FILE = '/virtual/Greeting.vue'; + +test('a component compiles once and later processes read the cached module', (t) => { + const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const compiled = cachedVueModule(SOURCE, FILE, directory); + assert.equal(compiled, compileVueModule(SOURCE, FILE)); + const entries = readdirSync(directory); + assert.deepEqual(entries, [`${vueCacheKey(SOURCE, FILE)}.mjs`]); + // A cached entry is returned as stored, without compiling again. + writeFileSync(path.join(directory, entries[0]), 'export default "cached";'); + assert.equal( + cachedVueModule(SOURCE, FILE, directory), + 'export default "cached";', + ); +}); + +test('an edited component or another path is a new cache entry', () => { + const key = vueCacheKey(SOURCE, FILE); + assert.notEqual(vueCacheKey(`${SOURCE}\n`, FILE), key); + assert.notEqual(vueCacheKey(SOURCE, '/virtual/Other.vue'), key); + assert.equal(vueCacheKey(SOURCE, FILE), key); +}); + +test('GEV_VUE_CACHE=0 compiles every time; otherwise the cache sits in a per-user directory', () => { + assert.equal(vueCacheDirectory({ GEV_VUE_CACHE: '0' }), null); + if (process.platform !== 'win32') { + const directory = vueCacheDirectory({}); + assert.equal(path.dirname(directory), tmpdir()); + assert.match(path.basename(directory), /^gev-vue-sfc-/); + } + // Windows temp directories may be shared, so the cache lives in the + // user's profile, or not at all. + assert.equal( + vueCacheDirectory( + { LOCALAPPDATA: 'C:\\Users\\a\\AppData\\Local' }, + 'win32', + ), + path.join('C:\\Users\\a\\AppData\\Local', 'gev-vue-sfc'), + ); + assert.equal(vueCacheDirectory({}, 'win32'), null); + assert.equal( + cachedVueModule(SOURCE, FILE, null), + compileVueModule(SOURCE, FILE), + ); +}); + +test('a cache directory that cannot be written still returns the compiled module', (t) => { + const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + // A file where the cache directory should be makes every write fail. + const blocked = path.join(directory, 'blocked'); + writeFileSync(blocked, ''); + assert.equal( + cachedVueModule(SOURCE, FILE, blocked), + compileVueModule(SOURCE, FILE), + ); +}); + +test( + 'a cache directory others can write to, or a link to one, is never read', + { + skip: process.platform === 'win32', + }, + (t) => { + const root = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const shared = path.join(root, 'shared'); + mkdirSync(shared); + chmodSync(shared, 0o777); + // A module planted where the cache would look must not run. + writeFileSync( + path.join(shared, `${vueCacheKey(SOURCE, FILE)}.mjs`), + 'export default "planted";', + ); + assert.equal(ownCacheDirectory(shared), false); + assert.equal( + cachedVueModule(SOURCE, FILE, shared), + compileVueModule(SOURCE, FILE), + ); + const own = path.join(root, 'own'); + mkdirSync(own, { mode: 0o700 }); + const link = path.join(root, 'link'); + symlinkSync(own, link); + assert.equal(ownCacheDirectory(link), false); + assert.equal(ownCacheDirectory(own), true); + }, +);