Table of Contents
- The Evolution of Awesome Tec Stacks
- Building Our Concrete Example Stack
- Creating a Code Generation Script
- Using that Script
- Defining a Task Executor
- Using that Executor
- Putting it All Together
The Evolution of Awesome Tech Stacks
LAMP - the OG Tech Stack
Arguably the first tech stack that was called as such was the LAMP stack, which was traced back to a 1998 issue of the German computing magazine: Computertechnik.
LAMP here was an acronym that stood for:
L - Linux (the operating system)
A - Apache (the HTTP server)
M - MySql, or sometimes MariaDB (the database)
P - PHP / Perl / Python (the programing language)
There was something magical about this idea of a tech stack. This bundle of free-to-use, open-sourced, and relatively interchangeable software was a viable (maybe even preferable!) alternative to the many paid, proprietary, and "locking-in" packages that were popular at the time, and gave rise to some killer applications - like WordPress!
An interesting essential part of this idea of "a stack" was the concept of interchangeable pieces - note how the "P" in particular could stand for 3 VERY different programming languages, and a close cousin to LAMP was WAMP which exchanged Linux for Windows.
First-Gen JS stacks: MEAN/MERN
As we move on from the early 2000s to the 2010s - the JavaScript ecosystem, in particular, started growing in popularity, spurred by the creation of Node.js in 2009, which was accompanied by a JS package manager: npm.
With this popularity came the rise of the first all-JS stack - the MEAN stack, which stood for:
M - Mongo - a JSON (JavaScript Object Notation) based Database
E - Express - a Web Framework for Node.js
A - AngularJS - a frontend JS web framework
N - NodeJS - a backend Javascript runtime environment
A later evolution of this stack was the MERN stack that exchanged React with AngularJS as the frontend web framework.
While this first generation of JavaScript stacks made for a recognizable and searchable term for describing a typical setup, there was little in the way of standardizing this stack.
Current-Gen JS stacks: T3, tRPC, Tanstack
Stepping into the current generation of JavaScript in the 2020s - and while we're still early in this generation of JS stacks, a clear trend can be seen in the form of a focus on tooling and the developer experience.
A stand-outs from this generation of the JS stack is the t3 stack, which is an opinionated and evolving collection of the following technologies:
A marked difference we can see from the MEAN/MERN stacks of the previous generation is that while MEAN/MERN operated mainly as a convenient search term to give needed context to looking up problems, the t3 stack is much more formalized, including a website, a community of support on discord and Twitter, and importantly: a CLI tool to create a working starter application.
This CLI is exciting as it is a way of standardizing and versioning the stack over time. If the community identifies a specific tool or enhancement that it can canonically bring into the stack, the "generative" nature of this tool gives developers a working starting point. Still, they can work from there to bring in their tools to further customize their stack.
The impact on the developer, however, is the ultimate goal here, as the t3 tenants of ruthless practicality:
Developers should be focused on solving their problems.
Using create-t3-app
clearly demonstrates this: after answering a short series of questions, the developer has an operational full-stack application that they can see in action with one simple command to start up the project locally.
Other popular packages in this generation include the tanstack series of packages:
and tRPC:
While not fully-fledged stacks the way t3 is, these projects are likely successful mainly due to the attention given to tooling - particularly type safety. This positions them as obvious building blocks for other stacks to incorporate.
The Next Step: Nx
We think that Nx is uniquely positioned to be an essential tool in this current generation of JS stacks.
Nx is built on a series of core tooling features that benefit most repositories (especially monorepos), including:
- code generation mechanisms that allow you to quickly scaffold an entire project structure or add features such as Tailwind to an existing project
- a way of visualizing how sections of your code are connected via the project graph
- mechanisms to ensure speed while just running commands via the
nx affected
command and task caching - a mechanism for defining how commands in your workspace depend on each other via task pipeline configuration
- a way to easily extend tooling via a plugin mechanism
While all the above features help make things faster for any project, Nx's pluggability is particularly interesting for our discussion about stacks.
At Nx, we maintain many packages that are designed to be used as building blocks for developers to create their own stacks.
Framework-based packages like our React, Angular, Nest, and Node packages offer developers a way of assembling their own stack based on their preferences. For instance, adding a react application to your repository with react-router-dom installed and pre-configured to match their getting started guide is as simple as running the command:
npx nx g @nrwl/react:app my-app --routing
Then to complement this frontend application with a backend written with NestJS, you run the command:
npx nx g @nrwl/nest:app api --frontendProject=my-app
This will not only create a backend Nest application for you but also configure your React application to create a proxy for your API requests to this Nest application. This otherwise tedious task will often waste developer time.
Other tooling-based packages like our Vite, Webpack, and Rollup packages offer support for build, test, and Linting level tools allowing easy migration to the tool that best suits your preferences, with the added benefit of allowing for painless transitions from using webpack to vite or vice-versa.
In addition to the official Nx packages comes a registry of community-supported plugins that support other frameworks, tools, and languages that we don't have official support for:
These community-supplied plugins offer more building blocks to create your desired stack.
And our Nx plugin package provides an API for creating your own building blocks and composing other building blocks into a pre-defined stack - just like the t3 stack!
Building Our Concrete Example Stack
With this context in mind, let's dive into creating our concrete stack, specifically:
- React as our frontend Application
- Tailwind for styling on our frontend app
- Express for our backend application
- Vite and Vitest for frontend bundling and testing
- Esbuild for backend building
- tRPC for building our API and full-stack type safety across both apps
We can build upon Nx's official plugins for the first five items and write our generators for the tRPC pieces based on their "Getting Started" documentation.
The end goal is to have a codified and versioned stack that will allow us to create a full-stack application in one command and then be able to serve the entire working stack in a single command.
Here's a teaser of how this will all look when we're finished:
And you can see and clone the workspace we'll create here.
Creating our Plugin
Before starting this section, create an initial workspace using the command create-nx-workspace@latest nx-trpc-example --preset=empty
, and then install our dependencies by running the commands:
> yarn add -D @nrwl/react @nrwl/vite @nrwl/node @nrwl/nx-plugin @nrwl/esbuild
> yarn add @trpc/client @trpc/server
> npx nx g @nrwl/react:init
> npx nx g @nrwl/vite:init
> npx nx g @nrwl/node:init
> npx nx g @nrwl/esbuild:init
We'll need the above to install manually, but in the sequel to this guide, we'll create our own
init
generator script so that any consumers of our package won't need to run this step manually!
Next, to create our plugin, we'll run the command:
npx nx g @nrwl/nx-plugin:plugin plugin --minimal
This will use the plugin
generator of the @nrwl/plugin
package to create a plugin for our workspace named: plugin
. We're also using the --minimal
flag here, so the plugin is empty initially.
Once this command is run, we'll see the new project in: libs/plugin
The scaffolding of this project is separated into executors
and generators
- where executors
are an Nx mechanism to define a task (like building an app or starting a web server for local development) simplified to a simple Typescript function, and generators
are an Nx mechanism for code generation simplified to a simple Typescript function. We'll see these more in the following sections, starting with generators
:
Create a Full-Stack Application Generator
Nx's Generator API simplifies any code-gen script to a simple function. The @nrwl/devkit
package goes on to provide utility functions to make the more burdensome things about code-generation scripts more manageable and tolerable!
When we consider at a high level what our code generation should look like, we can separate it into these four steps:
- generate a front-end application
- generate a corresponding backend application
- generate a tRPC server library that is already imported into our backend application
- generate a tRPC client library that is already imported by our frontend application
Because first-party Nx generators from the official Nx packages are functions as well, we can create a corresponding function for all four of the points above and then call those Nx generators in sequence to implement our generator.
Now that we have our roadmap for the generator, we also need to consider how we'll want to parameterize our generator. The Nx CLI uses a schema-based approach to defining these options so that when another developer uses our code generation script, they can pass any of these options to the CLI, which will be passed as parameters to our generator function we'll write next.
Given this, let's limit our options for now to the following:
- the name of the full-stack application. This will be required, and we'll use this name to inform how we'll name the four projects to generate for this app: (
${name}-web
,${name}-server
,${name}-trpc-server
and${name}-trpc-client
). - the port for the backend and the frontend applications to run on. These won't be required - and we'll default them to 3000 and 3333, respectively.
With these in mind, we'll create our application generator using the command:
npx nx g @nrwl/nx-plugin:generator app --project=plugin
Which will use the generator
generator of the @nrwl/nx-plugin
package to create our new generator named "app" located at libs/plugin/src/generators/app
.
Once the command is run, you should see new files located in libs/plugin/src/generators/app
, and metadata for this new app
generator in libs/plugin/generators.json
.
The file: libs/plugin/generators.json
will define all the generators contained in our plugin to Nx - but we won't need to touch this file as the generator already took care of any changes required.
As for the other files created, we'll start with libs/plugin/src/generators/app/schema.d.ts
:
export interface AppGeneratorSchema {
name: string;
frontendPort?: number;
backendPort?: number;
}
This interface matches the parameterization of the generator we want to support mentioned above. We'll also want to adjust the libs/plugin/src/generators/app/schema.json
file to match this as well:
This schema file will inform all Nx tools (including the CLI validation, the CLI --help
option, and the Nx Console tool) what options are available for this generator.
Notice that the name
property is the only required parameter, and lines 11-14 above tell us that it is the first non-named option in our command's argument vector (or argv
).
npx nx generate @nx-trpc-example/plugin:app test
So, for example, the above command would create our new application with the name: "test", and frontendPort
and backendPort
would use their default values. We can add specific values for these options by providing it to our command like so:
npx nx generate @acme-dev/plugin:app test --frontendPort=3001
Next, we can remove the libs/plugin/src/generators/app/files
directory, as we won't use that mechanism for this generator.
The
@nrwl/nx-plugin
creates thisfiles
directory to support templating with Nx generators via thewriteFiles()
function created in thegenerator.ts
file.
The generators we're creating in this article are relatively simple, so we'll use string literals when changing a file's contents.
The implementation of our generator will be written in the libs/plugin/src/generators/app/generator.ts
file.
The mental model here is we will implement these steps in the following function:
import { Tree } from '@nrwl/devkit';
import { AppGeneratorSchema } from './schema';
export default async function (tree: Tree, options: AppGeneratorSchema) {
// ... implementation to go here!
}
Our tree
parameter represents a virtual file system of the current state of our repo. We'll use this Tree
API and utility functions from the @nrwl/devkit
package to mutate that tree
object until it matches the desired state.
You can find the entire contents of that file here, and you can see our original high-level four-step approach to the generator:
export default async function (tree: Tree, options: AppGeneratorSchema) {
// ...
await createReactApplication(tree, optionsWithDefaults, webAppName);
await createNodeApplication(
tree,
optionsWithDefaults,
serverName,
webAppName
);
await createTrpcServerLibrary(tree, optionsWithDefaults, trpcServerName);
await createTrpcClientLibrary(tree, optionsWithDefaults, trpcClientName);
}
That's most of our high-level view of what a generator is and how to create it - you can skip ahead to using the generator, but otherwise - let's dive in deeper by walking through the implementation of each of these functions next:
0. Adding Default Options and Project Names
As a preliminary step, we'll define the default values for the optional parameters (lines 4-7 below). Then we'll create our optionsWithDefaults
by spreading the defaultPorts
with the options
coming in from the command. This has the effect of overwriting the defaultPorts
if the user provides their own, but otherwise, the defaults are used.
We'll also import the names
function from the @nrwl/devkit
package so that we can kebab-case the name provided by the user. For example, if the user-provided name is "helloWorld", the fileName
of this will be "hello-world". We'll use this to get a name of our four projects in kebab-case to use later on in the generator.
Other cases supported by the
names()
function include:
- pascal case
console.log(names('helloWorld').className); // HelloWorld
- camel case:
console.log(names('helloWorld').propertyName); // helloWorld
- screaming snake case:
console.log(names('helloWorld').constantName); // HELLO_WORLD
- untouched:
console.log(names('uNtOuChEd').name); // uNtOuChEd
We'll use some of these later on.
1. Create Our React App
For this step, we'll create a function called createReactApplication()
:
async function createReactApplication(
tree: Tree,
options: AppGeneratorSchema,
webAppName: string
) {
// implementation will go here!!
}
Our first step in this function will be to use Nx's first-party React app generator. We can import it like so:
import { applicationGenerator as reactAppGenerator } from '@nrwl/react';
Notice we're renaming
applicationGenerator
toreactAppGenerator
because we'll be importing otherapplicationGenerator
s in future steps!
When it comes time to call the function, we'll call it like so:
Note that Typescript Intellisense can help us with the required and optional options in lines 7-13 above!
The options we've provided will set up the application with vite and vitest, and we'll give it the appropriate port. Note that if we ever wanted to adjust our bundler to webpack in the future, making that change is as simple as changing the bundler option here!
Also note that since the reactAppGenerator
we imported is an async function
, we want to make sure we await
it! Without awaiting, we could get a race condition that would give us interesting and undesired results.
It may be a good idea to test our generator out incrementally. For example, at this point, we can confirm that our React application is generated as expected.
To do this, I recommend using git to commit all changes you've made so far right before running the generator:npx nx g @nx-trpc-example/plugin:app test
You can check that things look good and then reset to discard all the results so you can continue building the generator:
git add . && git reset --hard HEAD
Another tool you have for previewing changes from your generator is the
--dry-run
option. This will list all files that would have been created, updated, or deleted by the generator without actually running them:npx nx g @nx-trpx-example/plugin:app test --dry-run
The Nx React plugin includes a generator for adding Tailwind to a React application, so we'll import it and call it next:
import { setupTailwindGenerator } from '@nrwl/react';
// ...
async function createReactApplication(
tree: Tree,
options: AppGeneratorSchema,
webAppName: string
) {
await reactAppGenerator(tree, {
name: webAppName,
linter: Linter.EsLint,
style: 'css',
e2eTestRunner: 'none',
unitTestRunner: 'vitest',
bundler: 'vite',
devServerPort: optionsWithDefaults.frontendPort,
});
await setupTailwindGenerator(tree, { project: webAppName });
// rest of implementation to come here!!
}
Our next step is to add the boilerplate to import the tRPC client (that we'll generate in step 4!) to our new app.tsx
file of our React application:
import { getWorkspaceLayout, names, Tree } from '@nrwl/devkit';
// ...
function createAppTsxBoilerPlate(tree: Tree, name: string) {
const { className, fileName } = names(name);
const { npmScope } = getWorkspaceLayout(tree);
const appTsxBoilerPlate = `import { create${className}TrpcClient } from '@${npmScope}/${fileName}-trpc-client';
import { useEffect, useState } from 'react';
export function App() {
const [welcomeMessage, setWelcomeMessage] = useState('');
useEffect(() => {
create${className}TrpcClient()
.welcomeMessage.query()
.then(({ welcomeMessage }) => setWelcomeMessage(welcomeMessage));
}, []);
return (
<h1 className="text-2xl">{welcomeMessage}</h1>
);
}
export default App;
`;
tree.write(`apps/${fileName}-web/src/app/app.tsx`, appTsxBoilerPlate);
}
Notice that we're anticipating our import statement to match the trpc-client library that we'll create in Step 4 and using the client to query for a welcomeMessage
that we'll define in our trpc-server library in Step 3.
Also, note that we use the write
method of the Tree
api here to write a file to our virtual file system.
Other methods on the
Tree
api include:
read()
exists()
delete()
rename()
isFile()
children()
listChanges()
changePermissions()
The
@nrwl/devkit
also includes a list of utility functions to manipulate ourtree
more deftly. You can find the whole list here
We're also using the getWorkspaceLayout
function of the devkit so that we can match the correct import scope that Nx uses by default for Typescript imports.
Our last step for now on the React application is to adjust the default port to match the port provided by the user:
We're also using the updateJson
utility function from the @nrwl/devkit
to update our React app's project.json
file to change the options.port
of our serve
target to the provided port (line 15 above).
With these pieces now created, we can finish our createReactApplication
function now:
async function createReactApplication(
tree: Tree,
options: AppGeneratorSchema,
webAppName: string
) {
await reactAppGenerator(tree, {
name: webAppName,
linter: Linter.EsLint,
style: 'css',
e2eTestRunner: 'none',
unitTestRunner: 'vitest',
bundler: 'vite',
devServerPort: options.frontendPort,
});
await setupTailwindGenerator(tree, { project: webAppName });
createAppTsxBoilerPlate(tree, options.name);
adjustDefaultDevPort(tree, options);
}
2. Create Our Backend Node App
We'll take a similar approach to create our Node application, starting by importing the @nrwl/node
application generator:
Notice that we are adding a frontendProject
to the options (line 19 above). This will add a proxy configuration to our React app's development server - allowing us to side-step CORS complications in our local environment.
We'll also adjust the contents of the main.ts
file that is already created after the Promise returned by this nodeAppGenerator()
resolves:
function createServerBoilerPlate(
tree: Tree,
name: string,
backendPort: number
) {
const { fileName } = names(name);
const { npmScope } = getWorkspaceLayout(tree);
const serverBoilerPlate = `/**
* This is not a production server yet!
* This is only a minimal backend to get started.
*/
import express from 'express';
import * as trpcExpress from '@trpc/server/adapters/express';
import { trpcRouter } from '@${npmScope}/${fileName}-trpc-server';
import { environment } from './environments/environment';
const app = express();
app.use('/api', trpcExpress.createExpressMiddleware({ router: trpcRouter }));
const port = environment.port;
const server = app.listen(port, () => {
console.log(\`Listening at http://localhost:\${port}/api\`);
});
server.on('error', console.error);
`;
tree.write(`apps/${name}-server/src/main.ts`, serverBoilerPlate);
tree.write(
`apps/${name}-server/src/environments/environment.ts`,
`export const environment = {
production: false,
port: ${backendPort},
};
`
);
}
Note that we're anticipating an import of the trpcRouter
that we'll generate in step 3, and we're also using the backendPort
from our options to write this port to the environment.ts
file, and thereby configure our backend port.
Putting these pieces together, our resulting function looks like so:
async function createNodeApplication(
tree: Tree,
options: AppGeneratorSchema,
serverName: string,
webAppName: string
) {
await nodeAppGenerator(tree, {
name: serverName,
js: false,
linter: Linter.EsLint,
unitTestRunner: 'none',
pascalCaseFiles: false,
skipFormat: true,
skipPackageJson: false,
frontendProject: webAppName,
});
createServerBoilerPlate(tree, options.name, options.backendPort);
}
3. Create a Library for Our tRPC Server
We'll use a similar approach with the @nrwl/js
library generator for this lib:
import { libraryGenerator as jsLibGenerator } from '@nrwl/js';
// ...
async function createTrpcServerLibrary(
tree: Tree,
options: AppGeneratorSchema,
trpcServerName: string
) {
await jsLibGenerator(tree, {
name: trpcServerName,
bundler: 'vite',
unitTestRunner: 'vitest',
});
// ... rest of the implementation to go here!
}
After the library is generated, we'll add the boilerplate to export our trpc from the index.ts
file created in this library:
function createTrpcServerBoilerPlate(tree: Tree, name: string) {
const { className } = names(name);
const trpcServerBoilerPlate = `import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const trpcRouter = t.router({
welcomeMessage: t.procedure.query((req) => ({
welcomeMessage: \`Welcome to ${name}!\`,
})),
});
export type ${className}TrpcRouter = typeof trpcRouter;
`;
tree.write(`libs/${name}-trpc-server/src/index.ts`, trpcServerBoilerPlate);
}
Note that this creates the welcomeMessage
query that we call from app.tsx
back in step 1!
After calling these functions, we'll make a few more adjustments to clean up this library:
- remove the ${libraryName}.ts file that was generated
- remove the ${libraryName}.spec.ts file that was generated
- add a sourceRoot property to our
project.json
file
These operations are simple enough that we can inline them in our createTrpcServerLibrary
:
async function createTrpcServerLibrary(
tree: Tree,
options: AppGeneratorSchema,
trpcServerName: string
) {
await jsLibGenerator(tree, {
name: trpcServerName,
bundler: 'vite',
unitTestRunner: 'vitest',
});
createTrpcServerBoilerPlate(tree, options.name);
tree.delete(`libs/${trpcServerName}/src/lib/${trpcServerName}.ts`);
tree.delete(`libs/${trpcServerName}/src/lib/${trpcServerName}.spec.ts`);
updateJson(tree, `libs/${trpcServerName}/project.json`, (json) => ({
...json,
sourceRoot: `libs/${trpcServerName}/src`,
}));
}
4. Create a Library for Our tRPC Client
We'll use the same approach and the same @nrwl/js
library generator for this lib as well:
import { libraryGenerator as jsLibGenerator } from '@nrwl/js';
// ...
async function createTrpcClientLibrary(
tree: Tree,
options: AppGeneratorSchema,
trpcClientName: string
) {
await jsLibGenerator(tree, {
name: trpcClientName,
bundler: 'vite',
unitTestRunner: 'none',
});
createTrpcClientBoilerPlate(tree, options.name);
tree.delete(`libs/${trpcClientName}/src/lib/${trpcClientName}.ts`);
updateJson(tree, `libs/${trpcClientName}/project.json`, (json) => ({
...json,
sourceRoot: `libs/${trpcClientName}/src`,
}));
}
function createTrpcClientBoilerPlate(tree: Tree, name: string) {
const { className, fileName } = names(name);
const { npmScope } = getWorkspaceLayout(tree);
const trpcClientBoilerPlate = `import { ${className}TrpcRouter } from '@${npmScope}/${fileName}-trpc-server';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
export const create${className}TrpcClient = () =>
createTRPCProxyClient<${className}TrpcRouter>({
links: [httpBatchLink({ url: '/api' })],
} as any);
`;
tree.write(
`libs/${fileName}-trpc-client/src/index.ts`,
trpcClientBoilerPlate
);
}
The client here is pretty trivial and not expected to change, so we'll use 'none' for our unitTestRunner
option to avoid adding unit tests for this lib.
Putting Our Whole Generator Together
With all this in place, we're good to go! To recap, here's the entirety of our function now:
import { getWorkspaceLayout, names, Tree, updateJson } from '@nrwl/devkit';
import { applicationGenerator as nodeAppGenerator } from '@nrwl/node';
import { libraryGenerator as jsLibGenerator } from '@nrwl/js';
import {
applicationGenerator as reactAppGenerator,
setupTailwindGenerator,
} from '@nrwl/react';
import { AppGeneratorSchema } from './schema';
import { Linter } from '@nrwl/linter';
const defaultPorts = {
frontendPort: 3000,
backendPort: 3333,
};
export default async function (tree: Tree, options: AppGeneratorSchema) {
const optionsWithDefaults = {
...defaultPorts,
...options,
};
const kebobCaseName = names(optionsWithDefaults.name).fileName;
const webAppName = `${kebobCaseName}-web`;
const serverName = `${kebobCaseName}-server`;
const trpcServerName = `${kebobCaseName}-trpc-server`;
const trpcClientName = `${kebobCaseName}-trpc-client`;
await createReactApplication(tree, optionsWithDefaults, webAppName);
await createNodeApplication(
tree,
optionsWithDefaults,
serverName,
webAppName
);
await createTrpcServerLibrary(tree, optionsWithDefaults, trpcServerName);
await createTrpcClientLibrary(tree, optionsWithDefaults, trpcClientName);
}
// omitting the function implementations, but they would go below here
Which reads like a list of exactly what we set out to do at the start!
The generators in your Local Plugin can serve as the expected scaffolding for our stack codified (as well as automated!).
This way, if you decide to alter your stack or change this scaffolding - you can adjust your generators via a Pull Request.
That pull request can serve as a discussion place to verify the change, and once merged, your stack will follow the new standard!
Use your Generator
To use the generator now, run the command:
npx nx generate @nx-trpc-example/plugin:app test
Where @nx-trpc-example
is the name of our workspace, plugin
is the name of the plugin we created, app
is the name of the generator we created, and test
is the name of the full-stack app we want to create.
After creating our test
app via the generator, we can run the command to start our server:
npx nx serve test-server
And in a separate terminal, we can run the command:
npx nx serve test-web
To run our react application that communicates with our server. This should give us our full-stack development environment to respond to our saves as long as these serve
commands are running. But wouldn't it be great to run this in the same terminal via a single command?
In the next section, we'll create an executor that will allow us to run both of these serves in a single command.
Create an Executor
Next, we'll add a way to run both the frontend and backend applications in a development mode in a single command. As it turns out, Nx gives us a way out of the box to do this via:
% npx nx run-many --target=serve
> NX Running target serve for 2 projects:
- test-server
- test-web
—————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————
> nx run test-server:serve
> nx run test-web:serve:development
Loading proxy configuration from: /Users/zackderose/nx-recipes/trpc-example-stack/apps/test-web/proxy.conf.json
➜ Local: http://127.0.0.1:3000/
Debugger listening on ws://localhost:9229/7c8bd276-1df5-43e2-81cc-7de68cde56c4
Debugger listening on ws://localhost:9229/7c8bd276-1df5-43e2-81cc-7de68cde56c4
For help, see: https://nodejs.org/en/docs/inspector
Listening at http://localhost:3333/api
[ watch ] build succeeded, watching for changes...
With that in place, both our client and server are started with the one command, but it's difficult to tell in the terminal which line belongs to which process, so we'll address this by creating an executor so that our resulting terminal looks like this:
(That's everything for the high-level view of understanding of executors! Feel free to skip ahead to how we can use this executor once it's created, or other stick around for an in-depth explanation of the implementation details!)
Our goal in Nx's task-running API is to simplify any task to a single function: an executor. When we use Nx to run a task (like with the command: npx nx build my-app
) Nx will determine the executor function attached to my-app
's build
target and call that executor function!
To create an executor that will serve both the front and backend applications, we'll start by running the command:
npx nx g @nrwl/nx-plugin:executor serve-fullstack --project=plugin
This will run the executor
generator of the @nrwl/nx-plugin
package to create an executor called serve-fullstack
in our plugin
library, with all the scaffolding managed for us.
Not unlike our generator, we can adjust the parameterization of the executor here, starting with the libs/plugin/src/executors/serve-full-stack/schema.d.ts
:
export interface ServeFullstackExecutorSchema {
frontendProject: string;
backendProject: string;
}
This will require us to provide the name of the frontendProject
and backendProject
in the options of any targets we set up for this executor. We'll also add the corresponding libs/plugin/src/executors/serve-full-stack/schema.json
file:
{
"$schema": "http://json-schema.org/schema",
"version": 2,
"cli": "nx",
"title": "ServeFullstack executor",
"description": "",
"type": "object",
"properties": {
"frontendProject": {
"type": "string",
"description": "The name of the frontend project to serve."
},
"backendProject": {
"type": "string",
"description": "The name of the backend project to serve."
}
},
"required": ["frontendProject", "backendProject"]
}
With that setup, we'll start in the libs/plugin/src/executors/serve-fullstack/executor.ts
file.
While we could take a similar approach as our generators and import the executors from our
@nrwl/...
packages, this is not as feasible for our use case.Long-running executors (a task that is expected to keep running until canceled, for instance: a
serve
- as opposed to executors that complete at some point, like abuild
) return anAsyncInterator
from the existing first-party Nx Executors currently, which are a bit more challenging to work with.
So instead, we'll use Node's child_process
api to use Nx's CLI to call the serve
's of our web app and server. Then we'll listen to emissions from these processes' stdio
and stderr
and log them with a good-looking prefix. In the end, it should look like this:
Starting our Server
We'll start by running the server first, as we'll need our server running for our web server's proxy to be created correctly when we start running it.
Here's the code to do so:
The startBackendServer()
function will return a Promise that never resolves. This will ensure that the task will never complete (this is another way of achieving a long-running task, in addition to the AsyncIterators
mentioned before).
Line 16 above creates the child process, and lines 19-20 will ensure that if our parent process ends (like if the user pressed CTRL-C
while it's running), the child process will be kill()
ed as well.
On lines 21-25, we'll also listen for the substring: 'build succeeded, watching for changes...'
here to start up our frontend serve next!
Note the second input here: the
ExecutorContext
on line 9 above.We won't use this in the generator in this example. Still, when Nx calls this function to run our executor, it will provide all of this potentially helpful context information in the second parameter here, which is useful for many other use cases!
Starting our React App
We'll use the following function to start up our frontend server:
async function startFrontendServer(frontendProject: string) {
return new Promise(() => {
const childProcess = exec(`npx nx serve ${frontendProject}`, {
maxBuffer: LARGE_BUFFER,
});
process.on('exit', () => childProcess.kill());
process.on('SIGTERM', () => childProcess.kill());
});
}
This should look very similar to the function we wrote to start our backendProject!
With this function in place, we can adjust our startBackendServer()
function now to call this only the first the backend server starts correctly:
Notice we added a variable to track whether we've started the frontend server before on line 2 above, and we added a check in line 10, just before calling our startFrontendServer()
function on line 11.
Adding Good-Looking Prefixes to Our logs
At this point, our executor is functional, but if we run it right now, the logs will be empty! We'll fix this by creating a function that, when given a ChildProcess
and a prefix
, will relay anything logged by the process to the parent's stdout
.
Since we want the prefixes to look nice, later, we'll use chalk
(that Nx already has as a dependency, so it's already installed) and add some logic to center the name of the project and make all prefixes take the same amount of space:
Lines 2-12 above create a reusable function to prefix every line of any incoming string and send it to the parent process's stdout
by calling console.log()
, and lines 13-16 set up hooks to call this function whenever the given ChildProcess
writes to stdout
or stderr
.
The function to padTargetName
on lines 19-25 above will ensure that our prefix is centered and the same length as any other project name.
All that's left is to put it all together now:
Notice lines 12-13 above determines the targeted prefix width, and we adjusted our startBackendServer()
and our startFrontendServer()
functions to accept this as a parameter as well, and lines 28-31 and 54-57 add our prefixTerminalOutput()
so both the ChildProcess
es are prefixed correctly.
Use your Executor
To use our executor, we'll start by adding a new target to our apps/test-web/project.json file
:
Lines 8-14 above add a new serve-fullstack
target to our test-web
app that we already created with our generator. Line 9 tells it to use the executor we just wrote, and lines 10-13 give that executor the required parameters we set up for it.
All that's left to do now is call this target from the Nx CLI:
npx nx serve-fullstack test-web
Putting it All Together
As a bonus step, now that we have our executor set up, we can automate this step of adding the serve-fullstack
target to the right project.json
file.
Going back to libs/plugin/src/generators/app/generator.ts
, and specifically the createReactApplication()
function, we can add another function called addFullStackServeTarget
and call it after generating the rest of our react application:
Starting at line 26, we use @nrwl/devkit
's updateJson()
function to do just this to the project.json
of the react app that we'll create in this generator.
As we can see in the result, the whole picture of what we've created allows us to "stamp out" any number of full stack applications to add to our monorepo's workspace, given simply a name (and optionally a frontend or backend port!). We are then fully set up to execute a full-stack serve where we can easily distinguish the logs from our frontend vs. backend serve processes, as we saw in our original teaser:
Recap
In this article, we looked at the history and evolution of tech stacks and saw how Nx is positioned to be an integral part of creating and maintaining tech stacks in the future!
While this plugin currently exists only within our current workspace that we built together, we'll follow up this blog post with another on how to publish and share your plugin with the broader community (including an init
generator for installing required packages, and a preset
generator for creating a new workspace from scratch!)
Learn more
- 🧠 Nx Docs
- 👩💻 Nx GitHub
- 💬 Nrwl Community Slack
- 📹 Nrwl Youtube Channel
- 🧐 Need help with Angular, React, Monorepos, Lerna, or Nx? Talk to us 😃
If you liked this, click the ❤️ and make sure to follow Zack and Nx on Twitter for more!
Top comments (0)