diff --git a/.github/workflows/sync-headers.yml b/.github/workflows/sync-headers.yml new file mode 100644 index 0000000..5a9459f --- /dev/null +++ b/.github/workflows/sync-headers.yml @@ -0,0 +1,52 @@ +name: Header Sync + +on: + workflow_dispatch: null + schedule: + - cron: "0 0 * * *" + +permissions: + contents: write + pull-requests: write + +jobs: + build: + runs-on: ubuntu-latest + name: Update headers from nodejs/node + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - shell: bash + id: check-changes + name: Check Changes + run: | + COMMIT_MESSAGE=$(npm run --silent update-headers) + VERSION=${COMMIT_MESSAGE##* } + echo $COMMIT_MESSAGE + npm run --silent write-symbols + CHANGED_FILES=$(git diff --name-only) + BRANCH_NAME="update-headers/${VERSION}" + if [ -z "$CHANGED_FILES" ]; then + echo "No changes exist. Nothing to do." + else + echo "Changes exist. Checking if branch exists: $BRANCH_NAME" + if git ls-remote --exit-code --heads $GITHUB_SERVER_URL/$GITHUB_REPOSITORY $BRANCH_NAME >/dev/null; then + echo "Branch exists. Nothing to do." + else + echo "Branch does not exists." + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT + echo "COMMIT_MESSAGE=$COMMIT_MESSAGE" >> $GITHUB_OUTPUT + fi + fi + - name: Create Pull Request + uses: peter-evans/create-pull-request@v4 + if: ${{ steps.check-changes.outputs.BRANCH_NAME }} + with: + branch: ${{ steps.check-changes.outputs.BRANCH_NAME }} + commit-message: ${{ steps.check-changes.outputs.COMMIT_MESSAGE }} + title: ${{ steps.check-changes.outputs.COMMIT_MESSAGE }} + author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> + body: null + delete-branch: true diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..127db70 --- /dev/null +++ b/.npmignore @@ -0,0 +1,2 @@ +scripts/ +.github/ diff --git a/package.json b/package.json index 1680474..877d7b8 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "url": "git://github.com/nodejs/node-api-headers.git" }, "scripts": { + "update-headers": "node --no-warnings scripts/update-headers.js", + "write-symbols": "node --no-warnings scripts/write-symbols.js" }, "version": "0.0.2", "support": true diff --git a/scripts/clang-utils.js b/scripts/clang-utils.js new file mode 100644 index 0000000..2f2b032 --- /dev/null +++ b/scripts/clang-utils.js @@ -0,0 +1,50 @@ +'use strict'; + +const { spawn } = require('child_process'); + +/** + * @param {Array} [args] + * @returns {Promise<{exitCode: number | null, stdout: string, stderr: string}>} + */ +async function runClang(args = []) { + try { + const { exitCode, stdout, stderr } = await new Promise((resolve, reject) => { + const spawned = spawn('clang', + ['-Xclang', ...args] + ); + + let stdout = ''; + let stderr = ''; + + spawned.stdout?.on('data', (data) => { + stdout += data.toString('utf-8'); + }); + spawned.stderr?.on('data', (data) => { + stderr += data.toString('utf-8'); + }); + + spawned.on('exit', function (exitCode) { + resolve({ exitCode, stdout, stderr }); + }); + + spawned.on('error', function (err) { + reject(err); + }); + }); + + if (exitCode !== 0) { + throw new Error(`clang exited with non-zero exit code ${exitCode}. stderr: ${stderr ? stderr : ''}`); + } + + return { exitCode, stdout, stderr }; + } catch (err) { + if (err.code === 'ENOENT') { + throw new Error('This tool requires clang to be installed.'); + } + throw err; + } +} + +module.exports = { + runClang +}; diff --git a/scripts/update-headers.js b/scripts/update-headers.js new file mode 100644 index 0000000..03e7c1f --- /dev/null +++ b/scripts/update-headers.js @@ -0,0 +1,189 @@ +'use strict'; + +const { writeFile } = require('fs/promises'); +const { Readable } = require('stream'); +const { resolve } = require('path'); +const { parseArgs } = require('util') +const { createInterface } = require('readline'); +const { inspect } = require('util'); +const { runClang } = require('./clang-utils'); + +/** + * @returns {Promise} Version string, eg. `'v19.6.0'`. + */ +async function getLatestReleaseVersion() { + const response = await fetch('https://nodejs.org/download/release/index.json'); + const json = await response.json(); + return json[0].version; +} + +/** + * @param {NodeJS.ReadableStream} stream + * @param {string} destination + * @param {boolean} verbose + * @returns {Promise} The `writeFile` Promise. + */ +function removeExperimentals(stream, destination, verbose = false) { + return new Promise((resolve, reject) => { + const debug = (...args) => { + if (verbose) { + console.log(...args); + } + }; + const rl = createInterface(stream); + + /** @type {Array<'write' | 'ignore'>} */ + let mode = ['write']; + + /** @type {Array} */ + const macroStack = []; + + /** @type {RegExpMatchArray | null} */ + let matches; + + let lineNumber = 0; + let toWrite = ''; + + rl.on('line', function lineHandler(line) { + ++lineNumber; + if (matches = line.match(/^\s*#if(n)?def\s+([A-Za-z_][A-Za-z0-9_]*)/)) { + const negated = Boolean(matches[1]); + const identifier = matches[2]; + macroStack.push(identifier); + + debug(`Line ${lineNumber} Pushed ${identifier}`); + + if (identifier === 'NAPI_EXPERIMENTAL') { + if (negated) { + mode.push('write'); + } else { + mode.push('ignore'); + } + return; + } else { + mode.push('write'); + } + + } + else if (matches = line.match(/^\s*#if\s+(.+)$/)) { + const identifier = matches[1]; + macroStack.push(identifier); + mode.push('write'); + + debug(`Line ${lineNumber} Pushed ${identifier}`); + } + else if (line.match(/^#else(?:\s+|$)/)) { + const identifier = macroStack[macroStack.length - 1]; + + debug(`Line ${lineNumber} Peeked ${identifier}`); + + if (!identifier) { + rl.off('line', lineHandler); + reject(new Error(`Macro stack is empty handling #else on line ${lineNumber}`)); + return; + } + + if (identifier === 'NAPI_EXPERIMENTAL') { + const lastMode = mode[mode.length - 1]; + mode[mode.length - 1] = (lastMode === 'ignore') ? 'write' : 'ignore'; + return; + } + } + else if (line.match(/^\s*#endif(?:\s+|$)/)) { + const identifier = macroStack.pop(); + mode.pop(); + + debug(`Line ${lineNumber} Popped ${identifier}`); + + if (!identifier) { + rl.off('line', lineHandler); + reject(new Error(`Macro stack is empty handling #endif on line ${lineNumber}`)); + } + + if (identifier === 'NAPI_EXPERIMENTAL') { + return; + } + } + + if (mode.length === 0) { + rl.off('line', lineHandler); + reject(new Error(`Write mode empty handling #endif on line ${lineNumber}`)); + return; + } + + if (mode[mode.length - 1] === 'write') { + toWrite += `${line}\n`; + } + }); + + rl.on('close', () => { + if (macroStack.length > 0) { + reject(new Error(`Macro stack is not empty at EOF: ${inspect(macroStack)}`)); + } + else if (mode.length > 1) { + reject(new Error(`Write mode greater than 1 at EOF: ${inspect(mode)}`)); + } + else if (toWrite.match(/^\s*#if(?:n)?def\s+NAPI_EXPERIMENTAL/m)) { + reject(new Error(`Output has match for NAPI_EXPERIMENTAL`)); + } + else { + resolve(writeFile(destination, toWrite)); + } + }); + }); +} + +/** + * Validate syntax for a file using clang. + * @param {string} path Path for file to validate with clang. + */ +async function validateSyntax(path) { + try { + await runClang(['-fsyntax-only', path]); + } catch (e) { + throw new Error(`Syntax validation failed for ${path}: ${e}`); + } +} + +async function main() { + const { values: { tag, verbose } } = parseArgs({ + options: { + tag: { + type: 'string', + short: 't', + default: await getLatestReleaseVersion() + }, + verbose: { + type: 'boolean', + short: 'v', + }, + }, + }); + + console.log(`Update headers from nodejs/node tag ${tag}`); + + const files = ['js_native_api_types.h', 'js_native_api.h', 'node_api_types.h', 'node_api.h']; + + for (const filename of files) { + const url = `https://raw.githubusercontent.com/nodejs/node/${tag}/src/${filename}`; + const path = resolve(__dirname, '..', 'include', filename); + + if (verbose) { + console.log(` ${url} -> ${path}`); + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Fetch of ${url} returned ${response.status} ${response.statusText}`); + } + + await removeExperimentals(Readable.fromWeb(response.body), path, verbose); + + await validateSyntax(path); + } +} + +main().catch(e => { + console.error(e); + process.exitCode = 1; +}); diff --git a/scripts/write-symbols.js b/scripts/write-symbols.js new file mode 100644 index 0000000..d3621c1 --- /dev/null +++ b/scripts/write-symbols.js @@ -0,0 +1,154 @@ +'use strict'; + +const { resolve: resolvePath } = require('path'); +const { writeFile } = require('fs/promises'); +const { runClang } = require('./clang-utils'); + +/** @typedef {{ js_native_api_symbols: string[]; node_api_symbols: string[]; }} SymbolInfo */ + +/** + * @param {number} [version] + * @returns {Promise} + */ +async function getSymbolsForVersion(version) { + try { + const { stdout } = await runClang([ '-ast-dump=json', '-fsyntax-only', '-fno-diagnostics-color', `-DNAPI_VERSION=${version}`, resolvePath(__dirname, '..', 'include', 'node_api.h')]) + + const ast = JSON.parse(stdout); + + /** @type {SymbolInfo} */ + const symbols = { js_native_api_symbols: [], node_api_symbols: [] }; + + for (const statement of ast.inner) { + if (statement.kind !== 'FunctionDecl') { + continue; + } + + const name = statement.name; + const file = statement.loc.includedFrom?.file; + + if (file) { + symbols.js_native_api_symbols.push(name); + } else { + symbols.node_api_symbols.push(name); + } + } + + symbols.js_native_api_symbols.sort(); + symbols.node_api_symbols.sort(); + + return symbols; + } catch (err) { + if (err.code === 'ENOENT') { + throw new Error('This tool requires clang to be installed.'); + } + throw err; + } +} + +/** @returns {Promise<{maxVersion: number, symbols: {[x: string]: SymbolInfo}}>} */ +async function getAllSymbols() { + /** @type {{[x: string]: SymbolInfo}} */ + const allSymbols = {}; + let version = 1; + + console.log('Processing symbols from clang:') + while (true) { + const symbols = await getSymbolsForVersion(version); + + if (version > 1) { + const previousSymbols = allSymbols[`v${version - 1}`]; + if (previousSymbols.js_native_api_symbols.length == symbols.js_native_api_symbols.length && previousSymbols.node_api_symbols.length === symbols.node_api_symbols.length) { + --version; + break; + } + } + allSymbols[`v${version}`] = symbols; + console.log(` v${version}: ${symbols.js_native_api_symbols.length} js_native_api_symbols, ${symbols.node_api_symbols.length} node_api_symbols`); + ++version; + } + + return { + maxVersion: version, + symbols: allSymbols + }; +} + +/** + * @param {SymbolInfo} previousSymbols + * @param {SymbolInfo} currentSymbols + * @returns {SymbolInfo} + */ +function getUniqueSymbols(previousSymbols, currentSymbols) { + /** @type {SymbolInfo} */ + const symbols = { js_native_api_symbols: [], node_api_symbols: [] }; + for (const symbol of currentSymbols.js_native_api_symbols) { + if (!previousSymbols.js_native_api_symbols.includes(symbol)) { + symbols.js_native_api_symbols.push(symbol); + } + } + for (const symbol of currentSymbols.node_api_symbols) { + if (!previousSymbols.node_api_symbols.includes(symbol)) { + symbols.node_api_symbols.push(symbol); + } + } + return symbols; +} + +/** + * @param {string[]} strings + */ +function joinStrings(strings, prependNewLine = false) { + if (strings.length === 0) return ''; + return `${prependNewLine ? ',\n ' : ''}'${strings.join("',\n '")}'`; +} + +async function getSymbolData() { + const { maxVersion, symbols } = await getAllSymbols(); + + let data = `'use strict' + +const v1 = { + js_native_api_symbols: [ + ${joinStrings(symbols.v1.js_native_api_symbols)} + ], + node_api_symbols: [ + ${joinStrings(symbols.v1.node_api_symbols)} + ] +} +`; + + for (let version = 2; version <= maxVersion; ++version) { + const newSymbols = getUniqueSymbols(symbols[`v${version - 1}`], symbols[`v${version}`]); + + data += ` +const v${version} = { + js_native_api_symbols: [ + ...v${version - 1}.js_native_api_symbols${joinStrings(newSymbols.js_native_api_symbols, true)} + ], + node_api_symbols: [ + ...v${version - 1}.node_api_symbols${joinStrings(newSymbols.node_api_symbols, true)} + ] +} +`; + } + + data += ` +module.exports = { + ${new Array(maxVersion).fill(undefined).map((_, i) => `v${i + 1}`).join(',\n ')} +} +` + return data; +} + +async function main() { + const path = resolvePath(__dirname, '../symbols.js'); + const data = await getSymbolData(); + console.log(`Writing symbols to ${path}`) + return writeFile(path, data); +} + +main().catch(e => { + console.error(e); + process.exitCode = 1; +});