UNPKG

protobufjs-loader

Version:

Webpack loader to translate .proto definitions to ProtoBuf.js modules

302 lines (269 loc) 8.19 kB
const fs = require('fs'); const { pbjs, pbts } = require('protobufjs-cli'); const protobuf = require('protobufjs'); const tmp = require('tmp'); const validateOptions = require('schema-utils').validate; const TARGET_STATIC_MODULE = 'static-module'; /** @type { Parameters<typeof validateOptions>[0] } */ const schema = { type: 'object', properties: { target: { type: 'string', default: TARGET_STATIC_MODULE, }, paths: { type: 'array', }, pbjsArgs: { type: 'array', default: [], }, pbts: { oneOf: [ { type: 'boolean', }, { type: 'object', properties: { args: { type: 'array', default: [], }, output: { anyOf: [{ type: 'null' }, { instanceof: 'Function' }], default: null, }, }, additionalProperties: false, }, ], default: false, }, }, additionalProperties: false, }; /** * Shared type for the validated options object, with no missing * properties (i.e. the user-provided object merged with default * values). * * @typedef {{ args: string[], output: ((resourcePath: string) => string | Promise<string>) | null }} PbtsOptions * @typedef {{ * paths: string[], pbjsArgs: string[], * pbts: boolean | PbtsOptions, * target: string, * }} LoaderOptions */ /** * The generic parameter is the type of the options object in the * configuration. All `LoaderOptions` fields are optional at this * stage. * * @typedef { import('webpack').LoaderContext<Partial<LoaderOptions>> } LoaderContext */ /** @type { (resourcePath: string, pbtsOptions: true | PbtsOptions, compiledContent: string | undefined) => Promise<void> } */ const execPbts = async (resourcePath, pbtsOptions, compiledContent) => { /** @type PbtsOptions */ const normalizedOptions = { args: [], output: null, ...(pbtsOptions === true ? {} : pbtsOptions), }; /** * Immediately run the function to get the typescript output path. If * the function is asynchronous, it will run in the background while * we kick off other async operations. * * @type { (resourcePath: string) => string | Promise<string> } */ const output = normalizedOptions.output === null ? (r) => `${r}.d.ts` : normalizedOptions.output; const declarationFilenamePromise = Promise.resolve(output(resourcePath)); // pbts CLI only supports streaming from stdin without a lot of // duplicated logic, so we need to use a tmp file. :( const compiledFilename = await new Promise((resolve, reject) => { tmp.file({ postfix: '.js' }, (err, result) => { if (err) { reject(err); } else { resolve(result); } }); }); // Write the compiled JS content to the tmp file. await new Promise((resolve, reject) => { fs.writeFile(compiledFilename, compiledContent || '', (err) => { if (err) { reject(err); } else { resolve(compiledFilename); } }); }); const declarationFilename = await declarationFilenamePromise; const pbtsArgs = ['-o', declarationFilename] .concat(normalizedOptions.args) .concat([compiledFilename]); /** @type { Promise<void> } */ const pbtsPromise = new Promise((resolve, reject) => { pbts.main(pbtsArgs, (err) => { if (err) { reject(err); } else { resolve(); } }); }); await pbtsPromise; }; /** * Main loader invocation. Return the pbjs-transformed content of a * protobuf source, and write typescript declarations if appropriate. * * @type { (source: string, context: LoaderContext) => Promise<string | undefined> } */ const loadSource = async (source, context) => { const defaultPaths = (() => { // eslint-disable-next-line no-underscore-dangle if (context._compiler) { // The `_compiler` property is deprecated, but still works as // of webpack@5. // // eslint-disable-next-line no-underscore-dangle return (context._compiler.options.resolve || {}).modules; } return undefined; })(); /** @type LoaderOptions */ const options = { target: TARGET_STATIC_MODULE, // Default to the module search paths given to the compiler. paths: defaultPaths || [], pbjsArgs: [], pbts: false, ...context.getOptions(), }; validateOptions(schema, options, { name: 'protobufjs-loader' }); /** * Get a tmp file location and write the file content. * * @type { string } */ const filename = await new Promise((resolve, reject) => { tmp.file((err, result) => { if (err) { reject(err); } else { resolve(result); } }); }); await new Promise((resolve, reject) => { fs.writeFile(filename, source, (err) => { if (err) { reject(err); } else { resolve(filename); } }); }); const { paths } = options; /** * Adapted from the import resolution setup in * https://github.com/dcodeIO/protobuf.js/blob/master/cli/pbjs.js. * * In addition to the main pbjs invocation, run a manual compilation * pass which resolves imports using the provided include paths, and * mark all visited imports as dependencies of the current resource. * * @type { Promise<protobuf.Root> } */ const loadDependencies = new Promise((resolve, reject) => { const root = new protobuf.Root(); // Set up the resolver which will mark dependencies as it goes. root.resolvePath = (origin, target) => { const normOrigin = protobuf.util.path.normalize(origin); const normTarget = protobuf.util.path.normalize(target); let resolved = protobuf.util.path.resolve(normOrigin, normTarget, true); const idx = resolved.lastIndexOf('google/protobuf/'); if (idx > -1) { const altname = resolved.substring(idx); if (altname in protobuf.common) { resolved = altname; } } if (fs.existsSync(resolved)) { // Don't add a dependency on the temp file if (resolved !== protobuf.util.path.normalize(filename)) { context.addDependency(resolved); } return resolved; } for (let i = 0; i < paths.length; i += 1) { const iresolved = protobuf.util.path.resolve(`${paths[i]}/`, target); if (fs.existsSync(iresolved)) { context.addDependency(iresolved); return iresolved; } } return null; }; // Perform the actual parsing/dependency resolution, and resolve // when finished, i.e. after all dependencies have been visited // and marked. protobuf.load(filename, root, (err, result) => { if (err || result === undefined) { reject(err); } else { resolve(result); } }); }); /** @type { string[] } */ let args = ['-t', options.target]; paths.forEach((path) => { args = args.concat(['-p', path]); }); args = args.concat(options.pbjsArgs).concat([filename]); /** * Run the pbjs compiler and get the compiled content. * * @type { string | undefined } */ const compiledContent = await new Promise((resolve, reject) => { pbjs.main(args, (err, result) => { if (err) { reject(err); } else { resolve(result); } }); }); // If appropriate, run the pbts compiler. if (options.pbts) { await execPbts(context.resourcePath, options.pbts, compiledContent); } // Ensure all dependencies are marked before returning a value. await loadDependencies; return compiledContent; }; /** @type { (this: LoaderContext, source: string) => void } */ module.exports = function protobufJsLoader(source) { const callback = this.async(); // Explicitly check this case, as the typescript compiler thinks // it's possible. if (callback === undefined) { throw new Error('Failed to request async execution from webpack'); } loadSource(source, this) .then((compiled) => { callback(undefined, compiled); }) .catch((err) => { callback(err, undefined); }); };