UNPKG

oxc-parser

Version:

Oxc Parser Node API

284 lines (249 loc) 11.5 kB
'use strict'; const os = require('node:os'); const bindings = require('../bindings.js'); module.exports = { rawTransferSupported, parseSyncRawImpl, parseAsyncRawImpl, prepareRaw, isJsAst, returnBufferToCache, }; // Import `eager.js` and `lazy.js` after the exports above, because of circular dependencies const { parseSyncRaw, parseAsyncRaw } = require('./eager.js'); module.exports.parseSyncRaw = parseSyncRaw; module.exports.parseAsyncRaw = parseAsyncRaw; const { parseSyncLazy, parseAsyncLazy } = require('./lazy.js'); module.exports.parseSyncLazy = parseSyncLazy; module.exports.parseAsyncLazy = parseAsyncLazy; function parseSyncRawImpl(filename, sourceText, options, deserialize) { const { buffer, sourceByteLen, options: optionsAmended } = prepareRaw(sourceText, options); bindings.parseSyncRaw(filename, buffer, sourceByteLen, optionsAmended); return deserialize(buffer, sourceText, sourceByteLen); } // User should not schedule more async tasks than there are available CPUs, as it hurts performance, // but it's a common mistake in async JS code to do exactly that. // // That anti-pattern looks like this when applied to Oxc: // // ```js // const asts = await Promise.all( // files.map( // async (filename) => { // const sourceText = await fs.readFile(filename, 'utf8'); // const ast = await oxc.parseAsync(filename, sourceText); // return ast; // } // ) // ); // ``` // // In most cases, that'd just result in a bit of degraded performance, and higher memory use because // of loading sources into memory prematurely. // // However, raw transfer uses a 6 GiB buffer for each parsing operation. // Most of the memory pages in those buffers are never touched, so this does not consume a huge amount // of physical memory, but it does still consume virtual memory. // // If we allowed creating a large number of 6 GiB buffers simultaneously, it would quickly consume // virtual memory space and risk memory exhaustion. The code above would exhaust all of bottom half // (heap) of 48-bit virtual memory space if `files.length >= 21_845`. This is not a number which // is unrealistic in real world code. // // To guard against this possibility, we implement a simple queue. // No more than `os.availableParallelism()` files can be parsed simultaneously, and any further calls to // `parseAsyncRaw` will be put in a queue, to execute once other tasks complete. // // Fallback to `os.cpus().length` on versions of NodeJS prior to v18.14.0, which do not support // `os.availableParallelism`. let availableCores = os.availableParallelism ? os.availableParallelism() : os.cpus().length; const queue = []; async function parseAsyncRawImpl(filename, sourceText, options, deserialize) { // Wait for a free CPU core if all CPUs are currently busy. // // Note: `availableCores` is NOT decremented if have to wait in the queue first, // and NOT incremented when parsing completes and it runs next task in the queue. // // This is to avoid a race condition if `parseAsyncRaw` is called during the microtick in between // `resolve` being called below, and the promise resolving here. In that case the new task could // start running, and then the promise resolves, and the queued task also starts running. // We'd then have `availableParallelism() + 1` tasks running simultaneously. Potentially, this could // happen repeatedly, with the number of tasks running simultaneously ever-increasing. if (availableCores === 0) { // All CPU cores are busy. Put this task in queue and wait for capacity to become available. await new Promise((resolve, _) => { queue.push(resolve); }); } else { // A CPU core is available. Mark core as busy, and run parsing now. availableCores--; } // Parse const { buffer, sourceByteLen, options: optionsAmended } = prepareRaw(sourceText, options); await bindings.parseAsyncRaw(filename, buffer, sourceByteLen, optionsAmended); const ret = deserialize(buffer, sourceText, sourceByteLen); // Free the CPU core if (queue.length > 0) { // Some further tasks waiting in queue. Run the next one. // Do not increment `availableCores` (see above). const resolve = queue.shift(); resolve(); } else { // No tasks waiting in queue. This CPU is now free. availableCores++; } return ret; } const ONE_GIB = 1 << 30, TWO_GIB = ONE_GIB * 2, SIX_GIB = ONE_GIB * 6; // We keep a cache of buffers for raw transfer, so we can reuse them as much as possible. // // When processing multiple files, it's ideal if can reuse an existing buffer, as it's more likely to // be warm in CPU cache, it avoids allocations, and it saves work for the garbage collector. // // However, we also don't want to keep a load of large buffers around indefinitely using up memory, // if they're not going to be used again. // // We have no knowledge of what pattern over time user may process files in (could be lots in quick // succession, or more occasionally in a long-running process). So we try to use flexible caching // strategy which is adaptable to many usage patterns. // // We use a 2-tier cache. // Tier 1 uses strong references, tier 2 uses weak references. // // When parsing is complete and the buffer is no longer in use, push it to `buffers` (tier 1 cache). // Set a timer to clear the cache when no activity for 10 seconds. // // When the timer expires, move all the buffers from tier 1 cache into `oldBuffers` (tier 2). // They are stored there as `WeakRef`s, so the garbage collector is free to reclaim them. // // On the next call to `parseSync` or `parseAsync`, promote any buffers in tier 2 cache which were not // already garbage collected back into tier 1 cache. This is on assumption that parsing one file // indicates parsing as a whole is an ongoing process, and there will likely be further calls to // `parseSync` / `parseAsync` in future. // // The weak tier 2 cache is because V8 does not necessarily free memory as soon as it's able to be // freed. We don't want to block it from freeing memory, but if it's not done that yet, there's no // point creating a new buffer, when one already exists. const CLEAR_BUFFERS_TIMEOUT = 10_000; // 10 seconds const buffers = [], oldBuffers = []; let encoder = null, clearBuffersTimeout = null; // Get a buffer (from cache if possible), copy source text into it, and amend options object function prepareRaw(sourceText, options) { if (!rawTransferSupported()) { throw new Error( '`experimentalRawTransfer` option is not supported on 32-bit or big-endian systems, ' + 'versions of NodeJS prior to v22.0.0, versions of Deno prior to v2.0.0, and other runtimes', ); } // Delete `experimentalRawTransfer` and `experimentalLazy` options let _; ({ experimentalRawTransfer: _, experimentalLazy: _, ...options } = options); // Cancel timeout for clearing buffers if (clearBuffersTimeout !== null) { clearTimeout(clearBuffersTimeout); clearBuffersTimeout = null; } // Revive any discarded buffers which have not yet been garbage collected if (oldBuffers.length > 0) { const revivedBuffers = []; for (let oldBuffer of oldBuffers) { oldBuffer = oldBuffer.deref(); if (oldBuffer !== undefined) revivedBuffers.push(oldBuffer); } oldBuffers.length = 0; if (revivedBuffers.length > 0) buffers.unshift(...revivedBuffers); } // Reuse existing buffer, or create a new one const buffer = buffers.length > 0 ? buffers.pop() : createBuffer(); // Get/create `TextEncoder` if (encoder === null) encoder = new TextEncoder(); // Write source into start of buffer. // `TextEncoder` cannot write into a `Uint8Array` larger than 1 GiB, // so create a view into buffer of this size to write into. const sourceBuffer = new Uint8Array(buffer.buffer, buffer.byteOffset, ONE_GIB); const { read, written: sourceByteLen } = encoder.encodeInto(sourceText, sourceBuffer); if (read !== sourceText.length) throw new Error('Failed to write source text into buffer'); return { buffer, sourceByteLen, options }; } // Get if AST should be parsed as JS or TS. // Rust side sets a `bool` in this position in buffer which is `true` if TS. function isJsAst(buffer) { // 2147483636 = (2 * 1024 * 1024 * 1024) - 12 // i.e. 12 bytes from end of 2 GiB buffer return buffer[2147483636] === 0; } // Return buffer to cache, to be reused. // Set a timer to clear buffers. function returnBufferToCache(buffer) { buffers.push(buffer); if (clearBuffersTimeout !== null) clearTimeout(clearBuffersTimeout); clearBuffersTimeout = setTimeout(clearBuffersCache, CLEAR_BUFFERS_TIMEOUT); clearBuffersTimeout.unref(); } // Downgrade buffers in tier 1 cache (`buffers`) to tier 2 (`oldBuffers`), // so they can be garbage collected function clearBuffersCache() { clearBuffersTimeout = null; for (const buffer of buffers) { oldBuffers.push(new WeakRef(buffer)); } buffers.length = 0; } // Create a `Uint8Array` which is 2 GiB in size, with its start aligned on 4 GiB. // // Achieve this by creating a 6 GiB `ArrayBuffer`, getting the offset within it that's aligned to 4 GiB, // chopping off that number of bytes from the start, and shortening to 2 GiB. // // It's always possible to obtain a 2 GiB slice aligned on 4 GiB within a 6 GiB buffer, // no matter how the 6 GiB buffer is aligned. // // Note: On systems with virtual memory, this only consumes 6 GiB of *virtual* memory. // It does not consume physical memory until data is actually written to the `Uint8Array`. // Physical memory consumed corresponds to the quantity of data actually written. function createBuffer() { const arrayBuffer = new ArrayBuffer(SIX_GIB); const offset = bindings.getBufferOffset(new Uint8Array(arrayBuffer)); const buffer = new Uint8Array(arrayBuffer, offset, TWO_GIB); buffer.uint32 = new Uint32Array(arrayBuffer, offset, TWO_GIB / 4); buffer.float64 = new Float64Array(arrayBuffer, offset, TWO_GIB / 8); return buffer; } let rawTransferIsSupported = null; // Returns `true` if `experimentalRawTransfer` is option is supported. // // Raw transfer is only supported on 64-bit little-endian systems, // and NodeJS >= v22.0.0 or Deno >= v2.0.0. // // Versions of NodeJS prior to v22.0.0 do not support creating an `ArrayBuffer` larger than 4 GiB. // Bun (as at v1.2.4) also does not support creating an `ArrayBuffer` larger than 4 GiB. // Support on Deno v1 is unknown and it's EOL, so treating Deno before v2.0.0 as unsupported. function rawTransferSupported() { if (rawTransferIsSupported === null) { rawTransferIsSupported = rawTransferRuntimeSupported() && bindings.rawTransferSupported(); } return rawTransferIsSupported; } // Checks copied from: // https://github.com/unjs/std-env/blob/ab15595debec9e9115a9c1d31bc7597a8e71dbfd/src/runtimes.ts // MIT license: https://github.com/unjs/std-env/blob/ab15595debec9e9115a9c1d31bc7597a8e71dbfd/LICENCE function rawTransferRuntimeSupported() { let global; try { global = globalThis; } catch (e) { return false; } const isBun = !!global.Bun || !!global.process?.versions?.bun; if (isBun) return false; const isDeno = !!global.Deno; if (isDeno) { const match = Deno.version?.deno?.match(/^(\d+)\./); return !!match && match[1] * 1 >= 2; } const isNode = global.process?.release?.name === 'node'; if (!isNode) return false; const match = process.version?.match(/^v(\d+)\./); return !!match && match[1] * 1 >= 22; }