Skip to content

Commit

Permalink
feat: add option for custom typescript output locations (#46)
Browse files Browse the repository at this point in the history
This adds the `output` option to the `pbts` config, which accepts a
function to map resource paths (i.e. protobuf file paths) to their
typescript declaration (`.d.ts`) paths.

This is an alternate approach to
#43.

---------

Co-authored-by: katel0k <[email protected]>
  • Loading branch information
kmontag and katel0k authored Jan 7, 2025
1 parent 19fb602 commit 8b29c3a
Show file tree
Hide file tree
Showing 3 changed files with 148 additions and 31 deletions.
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ module.exports = {
/* Enable Typescript declaration file generation via pbts.
*
* Declaration files will be written every time the loader runs.
* They'll be saved in the same directory as the protobuf file
* being processed, with a `.d.ts` extension.
* By default, they'll be saved in the same directory as the
* protobuf file being processed, using the same filename with a
* `.d.ts` extension.
*
* This only works if you're using the 'static-module' target
* for pbjs (i.e. the default target).
Expand All @@ -63,6 +64,24 @@ module.exports = {
/* Additional command line arguments passed to pbts.
*/
args: ['--no-comments'],

/* Optional function which receives the path to a protobuf file,
* and returns the output path (or a promise resolving to the
* output path) for the associated Typescript declaration file.
*
* If this is null (i.e. by default), declaration files will be
* saved to `${protobufFile}.d.ts`.
*
* The loader won't create any directories on the filesystem. If
* writing to a nonstandard location, you should ensure that it
* exists and is writable.
*
* default: null
*/
output: (protobufFile) =>
`/custom/location/${require('path').basename(
protobufFile
)}.d.ts`,
},

/* Set the "target" flag to pbjs.
Expand Down
75 changes: 47 additions & 28 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const schema = {
type: 'array',
default: [],
},
output: {
anyOf: [{ type: 'null' }, { instanceof: 'Function' }],
default: null,
},
},
additionalProperties: false,
},
Expand All @@ -49,7 +53,7 @@ const schema = {
* properties (i.e. the user-provided object merged with default
* values).
*
* @typedef {{ args: string[] }} PbtsOptions
* @typedef {{ args: string[], output: ((resourcePath: string) => string | Promise<string>) | null }} PbtsOptions
* @typedef {{
* paths: string[], pbjsArgs: string[],
* pbts: boolean | PbtsOptions,
Expand All @@ -67,24 +71,26 @@ const schema = {

/** @type { (resourcePath: string, pbtsOptions: true | PbtsOptions, compiledContent: string, callback: NonNullable<ReturnType<LoaderContext['async']>>) => any } */
const execPbts = (resourcePath, pbtsOptions, compiledContent, callback) => {
/** @type PbtsOptions */
const normalizedOptions = {
args: [],
...(pbtsOptions === true ? {} : pbtsOptions),
};

// pbts CLI only supports streaming from stdin without a lot of
// duplicated logic, so we need to use a tmp file. :(
new Promise((resolve, reject) => {
tmp.file({ postfix: '.js' }, (err, compiledFilename) => {
if (err) {
reject(err);
} else {
resolve(compiledFilename);
}
});
})
.then(
try {
/** @type PbtsOptions */
const normalizedOptions = {
args: [],
output: null,
...(pbtsOptions === true ? {} : pbtsOptions),
};

// pbts CLI only supports streaming from stdin without a lot of
// duplicated logic, so we need to use a tmp file. :(
/** @type Promise<string> */
const compiledFilenamePromise = new Promise((resolve, reject) => {
tmp.file({ postfix: '.js' }, (err, compiledFilename) => {
if (err) {
reject(err);
} else {
resolve(compiledFilename);
}
});
}).then(
(compiledFilename) =>
new Promise((resolve, reject) => {
fs.writeFile(compiledFilename, compiledContent, (err) => {
Expand All @@ -95,16 +101,29 @@ const execPbts = (resourcePath, pbtsOptions, compiledContent, callback) => {
}
});
})
)
.then((compiledFilename) => {
const declarationFilename = `${resourcePath}.d.ts`;
const pbtsArgs = ['-o', declarationFilename]
.concat(normalizedOptions.args)
.concat([compiledFilename]);
pbts.main(pbtsArgs, (err) => {
callback(err, compiledContent);
);
/** @type { (resourcePath: string) => string | Promise<string> } */
const output =
normalizedOptions.output === null
? (r) => `${r}.d.ts`
: normalizedOptions.output;
const declarationFilenamePromise = Promise.resolve(output(resourcePath));

Promise.all([compiledFilenamePromise, declarationFilenamePromise])
.then(([compiledFilename, declarationFilename]) => {
const pbtsArgs = ['-o', declarationFilename]
.concat(normalizedOptions.args)
.concat([compiledFilename]);
pbts.main(pbtsArgs, (err) => {
callback(err, compiledContent);
});
})
.catch((err) => {
callback(err, undefined);
});
});
} catch (err) {
callback(err instanceof Error ? err : new Error(`${err}`), undefined);
}
};

/** @type { (this: LoaderContext, source: string) => any } */
Expand Down
81 changes: 80 additions & 1 deletion test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ describe('protobufjs-loader', function () {
compile(path.join(this.tmpDir, 'basic'), { pbts: true }).then(() => {
// By default, definitions should just be siblings of their
// associated .proto file.
glob(path.join(this.tmpDir, '*.d.ts'), (globErr, files) => {
glob(path.join(this.tmpDir, '**', '*.d.ts'), (globErr, files) => {
if (globErr) {
throw globErr;
}
Expand Down Expand Up @@ -261,6 +261,85 @@ describe('protobufjs-loader', function () {
});
});

describe('with custom declaration output locations', function () {
/**
* Helper function to assert that declarations for the basic
* fixture can be saved to a custom location. Allows providing
* either a plain string location, or a promise resolving to
* the location.
*
* Return a promise resolving to true as a simple sanity check
* that all assertions completed successfully.
*
* @type { (tmpDir: string, location: string | Promise<string>) => Promise<boolean> }
*/
const assertSavesDeclarationToCustomLocation = (tmpDir, location) => {
let outputInvocationCount = 0;

/**
* @type { (input: string) => string | Promise<string> }
*/
const output = (input) => {
outputInvocationCount += 1;
assert.equal(
fs.realpathSync(input),
fs.realpathSync(path.join(tmpDir, 'basic.proto'))
);
return location;
};

return compile(path.join(tmpDir, 'basic'), {
pbts: {
output,
},
}).then(() => {
assert.equal(outputInvocationCount, 1);

return Promise.resolve(location).then((locationStr) => {
const content = fs.readFileSync(locationStr).toString();
assert.include(content, 'class Bar implements IBar');
return true;
});
});
};

it('should save a declaration file to a synchronously-generated location', function (done) {
tmp.dir((err, altTmpDir, cleanup) => {
if (err) {
throw err;
}
assertSavesDeclarationToCustomLocation(
this.tmpDir,
path.join(altTmpDir, 'alt.d.ts')
).then((result) => {
assert.isTrue(result);
cleanup();
done();
});
});
});

it('should save a declaration file to an asynchronously-generated location', function (done) {
tmp.dir((err, altTmpDir, cleanup) => {
if (err) {
throw err;
}
assertSavesDeclarationToCustomLocation(
this.tmpDir,
new Promise((resolve) => {
setTimeout(() => {
resolve(path.join(altTmpDir, 'alt.d.ts'));
}, 5);
})
).then((result) => {
assert.isTrue(result);
cleanup();
done();
});
});
});
});

describe('with imports', function () {
it('should compile imported definitions', function (done) {
compile(path.join(this.tmpDir, 'import'), {
Expand Down

0 comments on commit 8b29c3a

Please sign in to comment.