Building Node.js applications without dependencies
bonaroo.nl
bonaroo.nl
What's the big deal?
Your second thought might be 'oh, its because they let themselves use a huge amount of random gizmos!'.
Nope.. the actual root dependencies are relatively small in number, maybe 30-40 core dependencies.
The problem is, that the prevailing attitude in the ecosystem is to 'just use dependencies', for anything, like lpad. As 'I know, we'll just use the creditcard my daddy gave me'.
As long as your daddy is rich, you won't see a problem.
If your daddy isnt rich, you will notice that every 10 days as you run npm-build, it will proclaim 'hooray, there are now again 10-15 high-medium security in your 1500 dependencies'.
Combine this need to npm update continuously, with frontends happy disregard for typechecking and backwards compatibility, and you have a recipe for the plague,which we are currently living :-(
I consider 40 to be a lot, even for a huge enterprise project. I think without major refactoring half of them can be replaced with simple utility functions.
And that’s where this new fangled crap falls down: we have Django and .net apps running that are 10 years old, php and java ones 15-20 years old that all survived without much or any work. We have apps in js/typescript from 1 year ago that have bucketloads of deprecated, no longer supported, mandatory update warnings. It’s a pita so we are looking to move back to php or/and Django.
I can see that in a startup with a dedicated team, this would be no issue; you are on top of all and working daily.
Note as well that for some products (the more regulated of our products; financial/health/gov) have strict dependencies we all have to vet; in our experience of the past 4 years, it’s not a hallucination that npms change so often without any useful new features or fixes; just some useless dep updates that sometimes break things and weren’t needed, at all (you do not always need the latest version of everything ya know); I suspect (but maybe someone here knows) is that npms often update because it gives the authors more kudos somehow (as in; if posting many videos on TikTok ups your engagement; does that work for npm updates and new useless npms too?).
This happens a lot with the frontend ecosystem (where everything is a peer dependency of either react or webpack / rollup / whatever build system) but can happen on the backend too (where many things are plugins / middlewares of express or whatever server you're using and carry a peer dependency on express).
This is made more evident by the fact that, in its latest iterations, the npm project decided to install peer dependencies by default and break if their collective ranges are not compatible (before it just warned), essentially breaking CI if peer deps are broken (I'm not saying it's a bad move, before that a lot of applications essentially failed less but more obscurely).
Another issue is the fact that the Node project moves quite fastly and new versions of dependencies that you must support for security reasons may be dependant on newer Node engines. A simple dependency migration, in that case, might become a migration towards a new version of Node, that 1) in corporate environments might not be available 2) might clash with other dependencies being still dependent on earlier versions of the Node engine 3) might deprecate or remove some of the APIs you were using.
All this makes sense per se: upgrading dependencies is a complex topic that shouldn't be taken lightly. What makes less sense, imho, is the extreme customizability of a Node project: millions of available small dependencies that you can combine however you like, fractally (your deps do this as well, and their deps and so on).
The ecosystem would probably benefit from more monolithic dependencies that are less customizable. Rome was a project in this sense - a monolithic package of pre-configured, opinionated dev tooling - but now the cat is out of the bag. Less experienced developers will bikeshed if they can and npm is the perfect ecosystem to bikeshed your way to thousands of dependencies that are not upgraded regularly and after a couple of years become a nightmare to upgrade in bloc. That's why people are starting to write zero-deps code.
In short, they’re fine in moderation but each added is another liability. As such, it can be beneficial to take a judicious approach and only add them when the value they bring outweighs that liability.
You update dependencies as a full time job and it bites you because something breaks big way. But most likely you have some help to fix it and as you are on top you might need to put work to fix it but yeah still full time job.
You neglect updating dependencies and it bites you and you don’t even expect it just because you have to update something you did not expect will need it and you are now scrambling to do anything because you are messed up.
Besides I like writing my own code more than dealing with some lib shenanigans.
Ideally I wouldn’t have to worry about that.
If you skip any one of those, then your full-time job is longer about shipping features/value but just trying to get the damned thing to work.
From my experience, dependencies introduce complexity in a super-linear manner, and one should be very careful in introducing them. This is a general principle that goes beyond NPM.
I also tried prompting to ask if any dependencies have been eclipsed by more popular libraries, which seemed to produce results.
Might be worth experimenting to see if it can take some grunt work out of it. Or an automated report as a CI stage. Hmm.
That being said, I’m not against dependencies, but I do think you should think about them quite a lot. I can’t imagine not using Typescript, eslint or ts-jest as an example. Similarly I can’t imagine interacting with many Microsoft APIs without using their SDKs (even as painful as that can be at times). Or using not using React(or similar) for the frontend.
But we needed an OData client, and there were some available through NPM, but they were terrible. You could use them, but you wouldn’t have a good time. So we wrote our own, which is on NPM actually, but also a really good example of dangerous packages as we made a lot of breaking changes through the different versions as we were building it. If you had blindly used it, you would have had to put in a ton of maintenance. Something which isn’t really exclusive to the Node package environment. We have a C# application that uses RestSharp instead of the Microsoft SDKs (for God knows what reason) and it’s a few versions behind by now because they made some rather big changes that nobody wanted to spend time on. On the flip side, we’ve also used Dapper as an ORM and been very happy with that. So often it’s also a question of using dependencies where and when it makes sense.
But blindly going into them is a maintenance nightmare. The less usage packages see, and the fewer maintainers, and so on, also has a big impact on whether you can trust them or not. We update quite a few of our packages automatically after a one-two week period, and others we only update manually after vetting the code. So it’s always a resource question.
I've written Node/React web applications that break completely after a few months of not being updated, and after a year or more they need refactoring to remove deprecated/dead dependencies. And they always come with half a dozen security warnings that are impossible to get rid of.
I've seen the default Go linter aggregator have to drop half the linters because no-one updated them to use generics. It's annoying, but not so much that I want to dive in and fix someone else's linter.
I've spent hours learning the interface for ${useful library}, only to find they changed it in the latest release and they're not maintaining the old version.
And I'm certain that the massive use of dependencies will cause a huge security problem this decade. Best account I've read of this: https://medium.com/hackernoon/im-harvesting-credit-card-numb...
I use as few dependencies as possible these days.
1000s of packages with backwards-incompatible changes that seems to happen every few years in NodeJS land. Some packages have backwards-incompatible changes and you don't even know because you're not using the package yourself and suddenly the behavior of your code changes in some subtle way you'll find out about a few weeks after upgrading.
I think the problem is that NodeJS's standard library is either too barebones or nobody wants to use it, since there are libraries for all kinds of basic stuff and everyone ends up using different libraries (or different versions of the same library) for the same thing. HTTP clients, array/string manipulation and date/time handling are some notable examples.
Well... if only it was just deprecation messages... but some devs think the log is a good place for proselytism or adverts.
Classical examples of this are metrics clients, tracing clients, loggers, HTTP frameworks, routers, etc. Tools that are hard/impossible to spin out into a dedicated microservice but generic enough to warrant trying to share that code across many services.
These dependencies can have measurable impact on the user experience.
Some teams just toss the code over the wall so "it's someone else's problem." But I'd take a "every instance of my code in a production server is a production deployment" approach.
Being able to test your changes against real production traffic, knowing when you've made a change that is going to break services, owning that, being able to roll it back yourself. All of that is stuff I want in my dependency manager.
A dependency is more than something down inside a node_modules directory. It's a code path that executes alongside, or even directly handles incoming customer requests.
Trying to track down and upgrade my library/framework/w.e. when it's deployed across 100,000+ instances for 10,000+ services is... not fun.
Even less fun is trying to surgically update _only_ my code and not rebuild the entire world pulling in a huge pile of changes along with my changes.
Package management failed me when I tried to model deployments as a collection of stakeholders shipping code to production.
This isn't just an npm problem. This sucks in pretty much every package manager I've worked with. Don't get me started on apt... unpack an ar archive into a global namespace, run some Rube Goldberg scripts, and hope for the best.
The Nix paper gives me hope, but I never got around to using Nix in production before I moved on from large-scale deployments.
I've also used pretty much every package manager out there, and this may hurt some, but I've always loved the npm ecosystem the most.
Here's what I have found so far:
- JavaScript (vanilla) is a viable alternative to React.js
- HTML entities (UTF-8) are an alternative to, for example, font-awesome
- Parcel.js is great for bundling cross-browser compatible Javascript apps: simple to install and no configuration needed. By default Parcel.js supports every browser having 0.25% or more of the total amount of active web users.
- The HTML template tag and JavaScript work well as a replacement for template libraries like Pug
- ChatGPT4 is great at writing the skeleton code for a project
Future plans include experimenting with `node:test` for testing.
I would need to add external libraries to fully support the following features, but I think I will continue on the zero-dependencies path:
- Sanitizing HTML
- Code highlighting
- Markdown rendering
The code can be found here: https://github.com/christianhellsten/ollama-html-ui/
When you're building something and you notice one of your dependencies has a bug or is missing a key feature that you need, do you (a) PR the fix into the dependency and then try to "harrass" the maintainer into merging it for you, (b) publish your own fork of the dependency with the necessary fix, (c) inline the source code for the dependency into your project, effectively taking it on as if it's your own code, (d) completely rewrite the dependency either as a separate package you control or built directly into your own project, or (e) code around the problem / do a hack?
I find that often maintainers are so over-worked that it's practically impossible to get a merge in a timely manner, and this leads me to rely on a fork until the PR eventually gets merged. However, I think creating a new package under either your own ownership or the company you work for is often really bad as it can become a kind of hidden technical debt. Nowadays, I definitely consider inlining as a way to capture ownership of the technical debt in a way that is highly visible, but this can add 100s or 1000s of lines of code to a project and if eventually the upstream project moves on you don't get the benefits of their changes without removing the inlined code and untangling any changes that were made to it.
The only other approach I've seen is the 'hack' approach, in which you try to dodge the bug or semantic issue. Honestly, that might be the right thing to do in some situations, but it isn't very hygienic within a long-term project (unless you carefully maintain a TODO list of things that need 'correct' solutions).
Lets say you've used FFTS, the Fastest Fourier Transform in the South [0]. Performance is spectacular, but now it's no longer supported and you find a bug. The code is impressive: Thousands of lines of code that dynamically generate SIMD instructions. But it's impossible to understand. You have chosen a library you cannot repair yourself. This is fine when there's alternatives, and there are for FFTs, and they are easy to swap out. If you can't easily swap out one library with another, you better make sure that you can repair it yourself.
And I hope people don't harass or "harass" library authors into merging pull requests...
This adds a diff to your repo, not an entire fork of the dependency.
A package manager like pnpm will install the package as usual, then apply your patch over the top.
Sure some specialist tooling is high quality and you should then contribute to it (if it’s in your critical path), but the advantage that is lost by not betting on the platform and properly learning it is huge.
I still have to find a way to store data and I really don't want to create my own database, but I haven't found a database in the standard library.
I'd consider this an acceptable import ;)
No developer is an island. [1]
I also don't get what the idea is of the JS testing: "I just want to know if some content is or isn’t rendered". Isn't the easiest way to do this to make a request to the URL via the browser and see for yourself? Or am I being unprofessional?
Thanks :) I tend to write messy. I'll give future blogs some more revisions to reduce the mess.
> Isn't the easiest way to do this to make a request to the URL via the browser and see for yourself?
In the applications I'm usually working with, there are many pages, and in many cases they do things differently based on the current context (permissions, roles, state of the business, date)
I generally test every route at least twice; one happy flow, and one unhappy flow (trying to submit a form to trigger validation errors, or trying to visit a page you should have no access to), but generally there are many tests per route.
I have routes that have literally 1000s of lines of tests for every "real world" case imaginable. Additionally, every bug that's found gets reproduced with a test before it's fixed.
The whole web application is usually just gathering params and converting them to domain logic calls, and taking the return value and converting these to a HTML document as a response.
The domain logic is fully tested, and the web controller is tested separately by some very basic tests (a simple GET to see if the form fields are present, a POST to see if errors are rendered, and to see if there's no error in the template). Occasionally I will replace the domain logic modules by mocks if the domain logic modules are complex or slow, to keep tests isolated.
Because the domain logic modules are tested thoroughly AND the web controllers that invoke the domain logic modules are also tested, most pages (that are backed by modules representing the domain logic) are tested multiple times in different ways. It is not uncommon for me to have widely more LOC for tests than actual code.
There's currently 2300 tests in my largest NodeJS project and the whole suite runs in about 30 seconds, but most modules can be tested in less than a second. It is only the "main" module that's slow because it's actually starting the whole application and being tested by actual over-the-network HTTP requests using fetch while the application is also doing real API calls in the background to external services which are mocked in other tests.
1000 of lines to test a single route? What logic is on the page? I just find it hard to wrap my mind around.
No matter how fancy a web page presents itself, isn't it always in essence a form that triggers a thing, like a post to a db?
It's not just the form but also the data it needs to be loading and saving
> Normally, I use express as a webserver, and I use jest for testing. ... Naturally, without dependencies, I can’t use any of these things.
But the examples are still using Jest from the look of it.