Node.js Quickstart — from Install to a First Request¶
What you get from this chapter
You can install the packages and run a minimal project where two processes call each other.
The project lives at
framework/languages/node/quickstart/. The code
blocks below are read from those files when the site is built. Without a location store, two
processes name each other's endpoint directly and exchange one request/reply.
0. Clone the examples repository¶
This chapter's project is quickstart/ in the zlink-node-examples repository; tutorial/,
the program read by the feature guides, and samples/ live beside it.
git clone https://github.com/zlink-systems/zlink-node-examples.git
cd zlink-node-examples/quickstart
main is the latest release plus the fixes merged since, with package versions pinned to that
release. An older release is the tag vA.B.C (git checkout vA.B.C). Send issues and PRs to
zlink-systems/zlink.
1. Installation¶
- Node.js 22 or later (
enginesof@zlink-systems/zlink) - Access to npm
Get it from npm. The minimal combination needed to build one server is the following.
npm install @zlink-systems/framework # The contract and runtime
npm install @zlink-systems/nestjs # DI/module registration
Do not list @zlink-systems/zlink (the binding) yourself. @zlink-systems/framework
declares the version it depends on.
{
"name": "zlink-quickstart",
"private": true,
"version": "0.0.0",
"scripts": {
"build": "tsc -p tsconfig.json",
"server": "node -e \"require('node:fs').existsSync('dist') || (console.error('run npm run build first'), process.exit(1))\" && node dist/Server/main.js",
"client": "node -e \"require('node:fs').existsSync('dist') || (console.error('run npm run build first'), process.exit(1))\" && node dist/Client/main.js"
},
"dependencies": {
"@zlink-systems/framework": "0.30.0",
"@zlink-systems/nestjs": "0.30.0",
"@nestjs/common": "10.4.22",
"@nestjs/core": "10.4.22",
"reflect-metadata": "0.2.2"
},
"devDependencies": {
"typescript": "5.8.3",
"@types/node": "22.15.30"
}
}
Packages to add when you need them:
| Package | When to add it |
|---|---|
@zlink-systems/framework-locations-redis |
When using the Redis location store for auto-connect (Location) |
@zlink-systems/framework-codec-protobuf · -codec-msgpack |
To use instead of the default JSON codec (Handlers and Message Processing) |
@zlink-systems/stream-connector |
When building an external client (a game client, mobile) (STREAM) |
@zlink-systems/http-client |
When the server calls out over HTTP (HTTP Client guide) |
The license differs by layer — core/binding is MPL-2.0, framework is FSL-1.1-ALv2, and
@zlink-systems/http-client is Apache-2.0. There is no cost to building and selling a service.
2. Shared contract¶
The request type must be a class. The packet name comes from payload.constructor.name, so
an object literal cannot be used. The reply type is not looked up by name and stays an
interface.
// Request/reply contract shared by the server and client processes.
//
// `Hello` is a class because the framework derives the wire packet name from
// `payload.constructor.name`; an object literal's constructor is `Object`,
// which it refuses. `Greeting` is decoded by shape, so it stays an interface.
export class Hello {
constructor(readonly name: string) {}
}
export interface Greeting {
readonly text: string;
}
// The packet name the request is registered and dispatched under.
export const PacketNames = {
hello: 'Hello'
} as const;
3. The handling side¶
A handler is registered in two places — Nest's providers and addRequestHandler on
channel(...).server(). This example binds to 127.0.0.1, so only processes on the same host connect and
Windows shows no firewall prompt. When other hosts must connect, bind to a reachable address and advertise
it with setAdvertiseHost.
import 'reflect-metadata';
import { Injectable, Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ZLinkModule, zlinkFramework } from '@zlink-systems/nestjs';
import type { ZLinkMessageContext, ZLinkRequestHandler } from '@zlink-systems/framework';
import { Greeting, Hello, PacketNames } from '../Shared/contracts';
// Handles one request on the "greeting" channel.
@Injectable()
class HelloHandler implements ZLinkRequestHandler<Hello, Greeting> {
async handle(request: Hello, context: ZLinkMessageContext): Promise<Greeting> {
void context;
return { text: `hello, ${request.name}` };
}
}
@Module({
imports: [
ZLinkModule.forRootFactory({
useFactory: () => {
const builder = zlinkFramework();
// Names the mesh and opens this process's endpoint for peers to connect to.
// Advertise the loopback address used by this local example.
const mesh = builder
.addRouteMesh('services')
.listen('tcp://127.0.0.1:7101')
.setAdvertiseHost('127.0.0.1');
// This process handles the "greeting" channel. IZLinkRequestHandler classes
// register on the channel builder explicitly -- see README "differences".
mesh.channel('greeting').server().addRequestHandler(PacketNames.hello, HelloHandler);
return builder.build();
}
})
],
providers: [HelloHandler]
})
class ServerModule {}
async function main(): Promise<void> {
await NestFactory.createApplicationContext(ServerModule, { logger: ['error', 'warn', 'log'] });
console.log('server listening on tcp://127.0.0.1:7101 (channel "greeting")');
}
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
4. The calling side¶
import 'reflect-metadata';
import * as http from 'node:http';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ZLINK_ROUTE_CLIENT, ZLinkModule, zlinkFramework } from '@zlink-systems/nestjs';
import type { ZLinkRouteClient } from '@zlink-systems/framework';
import { Greeting, Hello } from '../Shared/contracts';
@Module({
imports: [
ZLinkModule.forRootFactory({
useFactory: () => {
const builder = zlinkFramework();
// This process also opens and advertises its own loopback endpoint.
const mesh = builder
.addRouteMesh('services')
.listen('tcp://127.0.0.1:7102')
.setAdvertiseHost('127.0.0.1');
// This side only calls; it does not handle "greeting".
mesh.channel('greeting').client();
// Manual connection -- the server's endpoint is given directly.
mesh.peerConnections().connect('tcp://127.0.0.1:7101');
return builder.build();
}
})
]
})
class ClientModule {}
// This process's own HTTP surface -- a plain node:http server, not a NestJS
// HTTP module. See README "differences" for why.
function startHttpServer(route: ZLinkRouteClient): http.Server {
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
const match = request.method === 'GET' ? /^\/hello\/([^/]+)$/.exec(url.pathname) : null;
if (match === null) {
response.writeHead(404).end();
return;
}
const name = decodeURIComponent(match[1]);
try {
const reply = await route.requestToChannel('greeting', new Hello(name)).submit<Greeting>();
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify(reply.text));
} catch (error: unknown) {
response.writeHead(500, { 'content-type': 'application/json' });
response.end(
JSON.stringify({ error: error instanceof Error ? error.message : String(error) })
);
}
});
server.listen(5080, '127.0.0.1');
return server;
}
async function main(): Promise<void> {
const app = await NestFactory.createApplicationContext(ClientModule, {
logger: ['error', 'warn', 'log']
});
const route = app.get<ZLinkRouteClient>(ZLINK_ROUTE_CLIENT, { strict: false });
startHttpServer(route);
console.log('client listening on http://127.0.0.1:5080');
}
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
5. Run¶
cd zlink-node-examples/quickstart
npm install
npm run build
# Two terminals. Start the server first.
npm run server
npm run client
curl http://127.0.0.1:5080/hello/world
The response is "hello, world" with status 200.
6. What to check when the first run fails¶
| Symptom | What to check |
|---|---|
| A package is not found | Check that the package names in section 1 were copied exactly. The binding version is not pinned separately |
| Startup fails | Check that both processes name the same mesh, and that the listen endpoint does not collide with another process |
| The packet name does not match | Check that the request type is declared as a class. An object literal carries no name |
| A call ends with no target | Check that the receiving side registered that channel name in the server role, and that the handler is also in providers |
| No answer arrives | Check that the caller used request. A send receives no answer |
7. What to carry over¶
| File | Content |
|---|---|
package.json |
The @zlink-systems/framework and @zlink-systems/nestjs entries. The binding stays transitive |
Shared/contracts.ts |
Request as a class, reply as an interface |
Server/main.ts |
addRouteMesh → listen → setAdvertiseHost → channel(...).server().addRequestHandler(...), handler in providers |
Client/main.ts |
channel(...).client(), peerConnections().connect(...), requestToChannel(...).submit<T>() |
8. What to read next¶
These two processes connect by writing each other's endpoint directly. Keeping the calling code unchanged while servers are added or restarted at another address needs automatic connection, and that is covered by Location.
- To go over the concepts first — Core Concepts
- The path that calls by name — Channel Messaging
- State objects called by id — Spot · Actor
- To see a complete business flow — Picking a Sample