Skip to content

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 (engines of @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.

package.json
{
  "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.

Shared/contracts.ts
// 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.

Server/main.ts
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

Client/main.ts
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>()

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.