Update Zeit has just released the official way to use private NPM modules, see the announcement.
- Example application
- Authentication using NPM_TOKEN
- Shrink packing
- Bundling the server code
- Bundling the mock read-only file system
- Writing the temp file system
- Installing nothing
- Conclusion
I have been playing with Zeit.co - a super quick immutable deployment environment targeted at Node projects. If I have a server in a local folder, it could be deployed to new server almost instantly. It was a perfect solution for deploying a small web hook, or a chat server.
Yet there were two limitations:
- lack of environment variables support; this was simple to work around using a separate private repo and a tool like as-a
- the deployment tool does not install any private NPM dependencies due to lack of NPM authentication
This post shows how I worked around the second limitation by bundling the entire server code using browserify, preparing a single source file to be deployed. At the end there will be just a single JavaScript deployed, with zero NPM dependencies.
Example application
As an example I took a chat application server implemented on top of Feathers framework. The original example already had some modifications to make it work on Zeit
- No Socket.io on Zeit - I have left the REST api only
- Local data can be written into
/tmp
folder only. - (Optional) the server can determine its own host url using environment
variable
NOW_URL
I have published the chat app on NPM under name
feathers-chat-app-gleb, but to make the example
more realistic, I have included a dummy private NPM dependency.
The dependency @bahmutov/private-foo
comes from public GitHub repo
private-foo but the module has
been published as a private scoped module on NPM registry. The chat application
just prints the value exported by the private module
1 | const privateFoo = require('@bahmutov/private-foo'); |
How can we deploy this application to Zeit using the zeit now tool?
Authentication using NPM_TOKEN
First, let us try deploying the application as is.
1 | $ now -f |
So the deployment fails, but the install log passes way too fast to see the problem. To read the exception message, let us record the terminal output using the awesome asciinema utility.
1 | $ asciinema rec |
Opening the movie url, we can observe the detailed output, and even embed it below
Now we can see the install error clearly
1 | message: 'Response code 404 (Not Found)', |
The install fails, as it should;
the registry returns 404 when someone is requesting our
private module. Can we copy the personal NPM auth token into the local
.npmrc
file to allow Zeit deploy to install the private module? If we keep
the repo private, this could be acceptable solution. We can even git ignore
the .npmrc
file to avoid checking it into the repo (and even use
a tool like ban-sensitive-files to prevent this
from happening).
Yet, running the now
tool shows that the local .npmrc
file is ignored
and the private scoped module is still NOT installed. Hmm.
Shrink packing
If we cannot install private dependencies from the NPM registry, we could try including the downloaded dependencies directly in the git repo. The best way to keep code dependencies together with source is shrinkpack.
First, use npm shrinkwrap
command to "fix" the exact versions of all
dependencies, including the private ones. We are excluding the dev dependencies
though.
1 | $ npm prune |
The written file npm-shrinkwrap.json has every dependency resolution. Here is the start of the file.
1 | { |
Next, we will run the command to pack every resolved dependency back into
the .tar.gz
file (almost like it was downloaded from the NPM registry).
1 | $ npm install -g shrinkpack |
All the .tar.gz
files were placed into node_shrinkwrap
folder
1 | $ ls -l node_shrinkwrap/ |
The tool also updated the npm-shrinkwrap.json
file, setting the paths
to new local files
1 | { |
We can check in the node_shrinkwrap
folder into our Git repository, and
it will make npm install
instant from now on, because the registry will
NOT be used - all the module resolutions are present locally after cloning.
Trying now
deployment command, and ... it does not work. The now
command
only uploads the JavaScript files, NOT the .tar.gz
files. Even when I
forced it to upload by moving the node_shrinkwrap
folder into the src
folder, the installation command did not resolve the local .tar.gz
files.
Maybe the now
command does not respect the npm-shrinkwrap.json
, or
maybe there is some other reason. We need to find another way.
Bundling the server code
Our goal is to run the application. In the "normal" Node mode, each piece
of JavaScript can be loaded from a separate file using require(<filename>)
call. The <filename>
is resolved relative to the path, or from the
node_modules
folder. If we cannot have some files loaded from the
node_modules
because the install failed, we can try "bundling" the source
code into a single source file - just like we would for running an app
inside a browser!
I installed browserify and used a simple build.js to pass options that build a bundle targeted for Node environment (instead of the browser).
1 | var fs = require('fs') |
Let us build the bundle and see if we can run the project without
node_modules
folder.
1 | $ node build.js |
I also had to move folders config
and public
into src
for deployment
to include them.
We have all the code needed inside a single file src/out.js
. Let run use
this bundle when running now
. Oops, still there is a problem
1 | > Error: Cannot find module '/git/feathers-chat-app/src/config/default' |
The feathers-configuration module loads config
JSON files by looking through the files in the config
folder. This fails,
because the browserify does not include these - there is no way it can know
which files to include! So we need to hack around the config and just
load the files we need for this case ourselves. Change the src/app.js
to load the config/default.json
ourselves and set the properties.
1 | // app.configure(configuration(__dirname)); |
In the future I will refactor the feathers-configuration
module to
allow sending multiple config objects without loading them on the fly
from the file system.
Next, I removed favicon
middleware, since the .ico
file is not bundled
into JavaScript
1 | app |
At this point, the REST API is working on Zeit, which we can see if we try to grab the list of users, for example.
1 | $ http https://feathers-chat-app-gleb-kmljntdwmf.now.sh/users |
But if we try to open the deployed site in the browser we get 404!
Why do we not get served the HTML file public/index.html
?
Bundling the mock read-only file system
The deployed application does not include any non-javascript files
from the public
folder. We have the missing static pages there
1 | $ ls -l src/public/ |
Somehow we need to
- include the text contents of these files in the built bundle
- serve the bundled text for each file, as the server tries to read it from its "file system"
I love mocking environments using browserify, see blog posts Angular in WebWorker and Express in ServiceWorker. Our current problem looks like a fun little challenge.
The static folder in Feathers is served using the plain Express serve-static package, which uses send module to open a file stream to a file and pipe it back into the resource. If only these modules had a file system that pointed at contents from the bundle!
Luckily there is module mock-fs that does exactly this
1 | var mock = require('mock-fs'); |
During the build step we can collect all HTML files to be bundled into a single
file, then require
this mock object when we bundle the code. We are going
to turn on the mocking, and the server will have no choice but return the
bundled responses. I have described the steps on a tiny example in separate
repo browserify-server.
First, the build collects all public HTML and JS files and puts the text
into a single JSON object.
1 | const glob = require('glob') |
We also change the entry file for browserify to start.js
, which right
away requires the original index.js
file, and mocks the file system
using the file created.
1 | require('./src/index') |
To avoid getting into recursive bundling, I split the mock-fs
module name
in the require('mock-' + 'fs')
expression; this is a common trick to
stop browserify
from messing with things it should leave alone.
We can find our HTML inside the bundle
1 | $ grep "<title>" src/out.js |
We can even run the bundled code with the folder src/public
removed
1 | $ mv src/public src/tmp |
Deploying the bundle to zeit
and ... in production the module mock-fs
is not found! Seems Zeit does not let you mock the file system.
Ok, time to find one more work around.
Writing the temp file system
Zeit does allow you to write data into /tmp
folder. This is where we write
the messages database for example. We have the public
folder bundled into
our single application JavaScript file; we can just dump the files into the
/tmp
folder at the startup step. The server app then can serve
the static files normally.
The build file still collects all .js
and .html
files in the public
folder, and dumps their contents into a single .json
file
1 | function collectPublicFiles () { |
Then start.js
file dumps the collected contents into /tmp
folder
1 | function writePublicFiles () { |
Let us deploy and see if we finally got a happy server
1 | $ now |
From the terminal let us fetch the index page
1 | $ http https://feathers-chat-app-gleb-yhqzmzizjz.now.sh |
These are not tears of sadness, these are tears of happiness
You can check the app yourself at the above url https://feathers-chat-app-gleb-yhqzmzizjz.now.sh.
As the final change, I added bundling and writing the config JSON files
the same way to reuse the original feathers-configuration
code.
Installing nothing
To speed up the deploys, we can skip the dependency deployment on Zeit.
We have already bundled everything into the single src/out.js
file; there
is no need to run npm install
when deploying. We can write a simple
script to empty the dependencies
and devDependencies
in package.json
1 |
|
We will run the above script before the deployment, and will restore the
package.json
back to its full state after the deploy.
1 | { |
Let us try this. Without removing the superfluous node_modules
the bundling
and deployment takes 60 seconds.
1 | $ time npm run plain-deploy |
With just the bundling and moving just the single unchanged file it takes 25 seconds.
1 | $ time npm run deploy |
Fast and simple.
Conclusion
I wish Zeit had support for private modules, yet lack of this feature was a great opportunity for hacking together a work around. Notice that I had pursued multiple solutions that lead to a dead end. Yet every solution that did not work taught me something new. One just has to be patient and keep coming up with new ideas, chipping away at the problem.
The final solution is a weird one; I am sure the original problem has to do with browserified bundle not being able to properly load the regular files due to some path mangling. I will not pursue this further, instead I hope to see the Zeit team implement installation of private NPM modules using proper authentication.
You can find the code at bahmutov/feathers-chat-app and see the running application at https://feathers-chat-app-gleb-rjynpwiawt.now.sh
If you like hacking the code like me, read How to become a better hacker blog post.