From c2cc7d86f1bf8d8306b09f1e26f4e280c1011283 Mon Sep 17 00:00:00 2001 From: U9G Date: Sun, 30 Aug 2026 17:33:34 -0400 Subject: [PATCH] Add cacheFile option to compileProtoDefSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compileProtoDefSync evals the generated code, so every consumer process regenerates and reparses the full protocol on startup, and eval is invisible to V8's on-disk compile cache. With { cacheFile } the compiler saves the generated module to that path on first compile and loads it back with require on later runs. The require path calls module.enableCompileCache() (Node 22.8+, harmless no-op earlier), so V8 caches the parsed protocol across processes — that, not skipping generation, is where most of the time goes: a full Minecraft Java 26.1 client (four states, both directions via node-minecraft-protocol) drops from ~340ms to ~170ms of protocol setup per process on an idle M-series Mac. The cached module exports (native, PartialReadError) => factories, mirroring the locals the eval closes over, so no globals are involved (bedrock-protocol's pregenerated data files need global.PartialReadError today and could migrate to this). Invalidation stays with the caller, who knows what determines the generated code; any load or write failure falls back to the normal in-process compile. --- src/compiler.js | 40 ++++++++++++++++++++++++++++++++++++ test/compileCache.js | 49 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 test/compileCache.js diff --git a/src/compiler.js b/src/compiler.js index 9c8f057..d7c8227 100644 --- a/src/compiler.js +++ b/src/compiler.js @@ -39,6 +39,11 @@ class ProtoDefCompiler { } compileProtoDefSync (options = { printCode: false }) { + if (options.cacheFile) { + try { + return this.loadCompiledProtoDefSync(options.cacheFile) + } catch {} + } const sizeOfCode = this.sizeOfCompiler.generate() const writeCode = this.writeCompiler.generate() const readCode = this.readCompiler.generate() @@ -50,11 +55,46 @@ class ProtoDefCompiler { console.log('// Read:') console.log(readCode) } + if (options.cacheFile) { + try { + const fs = require('fs') + const path = require('path') + const file = path.resolve(options.cacheFile) + fs.mkdirSync(path.dirname(file), { recursive: true }) + const tmpFile = `${file}.${process.pid}.tmp` + fs.writeFileSync(tmpFile, 'module.exports = {\n' + + `sizeOf: (native, PartialReadError) => (${sizeOfCode})(),\n` + + `write: (native, PartialReadError) => (${writeCode})(),\n` + + `read: (native, PartialReadError) => (${readCode})()\n` + + '}\n') + // Rename so a concurrent process never requires a half-written file + fs.renameSync(tmpFile, file) + return this.loadCompiledProtoDefSync(file) + } catch {} + } const sizeOfCtx = this.sizeOfCompiler.compile(sizeOfCode) const writeCtx = this.writeCompiler.compile(writeCode) const readCtx = this.readCompiler.compile(readCode) return new CompiledProtodef(sizeOfCtx, writeCtx, readCtx) } + + // Loads code previously generated by compileProtoDefSync({ cacheFile }). + // The caller is responsible for cache invalidation: the file must have been + // generated from the same protocol, types and protodef version. + loadCompiledProtoDefSync (cacheFile) { + // The V8 compile cache is what makes a cache file cheaper than eval: + // both must parse the generated code, but only a require can skip that + // on later runs. Node <22.8 or a NODE_DISABLE_COMPILE_CACHE=1 opt-out + // degrades to a plain require. + try { require('module').enableCompileCache() } catch {} + const { PartialReadError } = require('./utils') + const mod = require(require('path').resolve(cacheFile)) + return new CompiledProtodef( + mod.sizeOf(this.sizeOfCompiler.native, PartialReadError), + mod.write(this.writeCompiler.native, PartialReadError), + mod.read(this.readCompiler.native, PartialReadError) + ) + } } class CompiledProtodef { diff --git a/test/compileCache.js b/test/compileCache.js new file mode 100644 index 0000000..8565745 --- /dev/null +++ b/test/compileCache.js @@ -0,0 +1,49 @@ +/* eslint-env mocha */ +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const os = require('os') +const { ProtoDefCompiler } = require('protodef').Compiler + +const protocol = { + container: 'native', + varint: 'native', + pstring: 'native', + packet: ['container', [ + { name: 'id', type: 'varint' }, + { name: 'msg', type: ['pstring', { countType: 'varint' }] } + ]] +} + +function makeCompiler () { + const compiler = new ProtoDefCompiler() + compiler.addTypesToCompile(protocol) + return compiler +} + +describe('compileProtoDefSync cacheFile', () => { + const cacheFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'protodef-test-')), 'proto.js') + after(() => fs.rmSync(path.dirname(cacheFile), { recursive: true, force: true })) + + const packet = { id: 42, msg: 'hello world' } + + it('writes the cache file on first compile and still works', () => { + const proto = makeCompiler().compileProtoDefSync({ cacheFile }) + assert.ok(fs.existsSync(cacheFile)) + const buf = proto.createPacketBuffer('packet', packet) + assert.deepStrictEqual(proto.parsePacketBuffer('packet', buf).data, packet) + }) + + it('loads from the cache file and round-trips identically', () => { + const proto = makeCompiler().compileProtoDefSync({ cacheFile }) + const buf = proto.createPacketBuffer('packet', packet) + assert.deepStrictEqual(proto.parsePacketBuffer('packet', buf).data, packet) + assert.deepStrictEqual(buf, makeCompiler().compileProtoDefSync({}).createPacketBuffer('packet', packet)) + }) + + it('falls back to in-process compile when the cache path is unwritable', () => { + const proto = makeCompiler().compileProtoDefSync({ cacheFile: path.join(cacheFile, 'not-a-dir', 'x.js') }) + const buf = proto.createPacketBuffer('packet', packet) + assert.deepStrictEqual(proto.parsePacketBuffer('packet', buf).data, packet) + }) +})