TypeScript with Fastify
Cover Page
Back-end Page
Install Node.js
We will first use the apt package manager to install Node.js version 18.19.1 or later (up to
version 26.3.1 tested), the pnpm package manager, and the tsgo TypeScript transpiler:
server$ sudo apt update
server$ sudo apt install libmimalloc2.0 undici npm # will automatically install node.js also
server$ sudo npm install -g @typescript/native-preview
# install pnpm
server$ curl -fsSL https://pnpm.io | sh -
Confirm that you’ve installed Node.js version 18.19.1 or later:
server$ node --version
# output:
v18.19.1
# or later
Installing other versions of Node.js
To install Node.js v26:
server$ curl -sL https://deb.nodesource.com/setup_26.x | sudo bash -
server$ sudo apt update
server$ sudo apt install nodejs # automatically install npm also
# or sudo apt upgrade
For other versions of Node.js, replace 26 with the version number you want.
To remove Node.js installed from nodesource:
server$ sudo apt purge nodejs
server$ sudo rm /etc/apt/sources.list.d/nodesource.list
See documentations on How to Install Node.js on Ubuntu and How to remove nodejs from nodesource.com
package.json and tsconfig.json
First create and change into a directory where you want to keep your harnessd package:
server$ mkdir ~/agentic/harnessd
server$ cd ~/agentic/harnessd
Create the file package.json with the following content:
{
"type": "module",
"scripts": {
"build": "pnpm exec tsgo && esbuild main.ts --bundle --platform=node --format=esm --external:readline/promises --external:better-sqlite3 --banner:js=\"import { createRequire } from 'module'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const require = createRequire(import.meta.url); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);\" --outfile=harnessd.js"
},
"devDependencies": {
"@typescript/native-preview": "7.0.0-dev.20260622.1"
}
}
This allows use of import statements instead of require() of CommonJS and install tsgo.
Install the following packages:
server$ pnpm install fastify http-status-codes
# later, for images: @fastify/static @fastify/multipart
server$ pnpm install -D esbuild @types/node
server$ pnpm approve-builds # hit space, then enter 'y' to approve building esbuild
Your ~/agentic/harnessd directory should now contain the following:
node_modules/ package.json pnpm-lock.yaml pnpm-workspace.yaml
The package.json file should now contain:
{
"type": "module",
"scripts": {
"build": "pnpm exec tsgo && esbuild main.ts --bundle --platform=node --format=esm --external:readline/promises --external:better-sqlite3 --banner:js=\"import { createRequire } from 'module'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const require = createRequire(import.meta.url); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);\" --outfile=harnessd.js"
},
"devDependencies": {
"@types/node": "^26.0.0",
"@typescript/native-preview": "7.0.0-dev.20260622.1",
"esbuild": "^0.28.1"
},
"dependencies": {
"fastify": "^5.8.5",
"http-status-codes": "^2.3.0",
"undici": "^8.5.0"
}
}
The devDependencies are used in development, not deployed in production. In our case, all the
devDependencies are for static type checking. The dependencies with @types prefix define the
types used in their corresponding native JavaScript modules that have no static typing information.
The file package-lock.json locks the dependencies to specific versions, to prevent automatically
incorporating later, breaking versions. The node_modules directory contains the module files of
both direct dependencies in package.json and indirect dependencies used by the direct
dependencies.
updates
Updating packages downloaded from the registry:
server$ pnpm self-update
server$ pnpm outdated
server$ pnpm update # -g # to update globally
Create a tsconfig.json configuration file for use by TypeScript:
server$ vi tsconfig.json
and put the following lines in it:
{
"compilerOptions": {
// https://aka.ms/tsconfig for definitions
// to use esbundler
"noEmit": true,
// save project graph for incremental build in tsconfig.tsbuildinfo
"incremental": true,
/* Language and Environment */
"target": "esnext", // version of emitted JavaScript and compatible library
"module": "esnext", // version of generated module code
"moduleResolution": "bundler", // how TypeScript looks up a file from module
/* Interop Constraints */
"verbatimModuleSyntax": true, // transform and elide only imports/exports marked
// "type"; the rest are emitted per the "module" setting
"esModuleInterop": true, // can import CommonJS modules; enables 'allowSyntheticDefaultImports'
/* Lazy Iterator */
"lib": ["ESNext"],
/* Type Checking */
"strict": true, // enable strict type-checking options
"alwaysStrict": true, // always enable all strict type-checking options
/* Null Check */
"strictNullChecks": true,
/* Type safe array access */
"noUncheckedIndexedAccess": true,
/* Completeness */
"skipLibCheck": true, // skip type checking all .d.ts files
"noImplicitAny": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"noImplicitOverride": true
}
}
Save and exit tsconfig.json.
You have installed all packages and set up all configurations needed by the harnessd back end.
main.ts
Create a file called main.ts:
server$ vi main.ts
Edit the file to add the following import lines:
import Fastify from "fastify"
import type { FastifyRequest, FastifyReply, RouteGenericInterface } from "fastify"
import type { Http2SecureServer, Http2ServerRequest, Http2ServerResponse } from "node:http2"
import { readFileSync } from 'node:fs' // import function
import type { AddressInfo } from 'node:net' // import type
import * as handlers from './handlers.js' // import namespace
We import our module handlers, which we will define later.
For logging purposes, we save the client’s IP as an extended property of the Fastify type definition.
We also specialize the FastifyRequest and FasitfyReply generic types to HTTP/2 traffic. Add the following lines:
declare module "fastify" {
interface FastifyRequest {
clientIp: string
}
}
export type FastifyHttp2Request = FastifyRequest<
RouteGenericInterface,
Http2SecureServer,
Http2ServerRequest
>
export type FastifyHttp2Reply = FastifyReply<
RouteGenericInterface,
Http2SecureServer,
Http2ServerRequest,
Http2ServerResponse
>
Next we instantiate a Fastify instance and configure it to:
- serve HTTP/2 with the
harnessdcertificate and key you created earlier, and -
automatically reroute any URL specified with a trailing ‘/’ to one without trailing ‘/’; for example
https://YOUR_SERVER_IP/llmprompt/will be rerouted tohttps://YOUR_SERVER_IP/llmprompt, We also configure it to include the newclientIPproperty to all instantiation ofFastifyRequest, populate the new property on request arrival, and to use it to log sent replies: ```ts try { const app = Fastify({ http2: true, https: { key: readFileSync(“/home/ubuntu/agentic/harnessd.key”), cert: readFileSync(“/home/ubuntu/agentic/harnessd.crt”),
allowHTTP1: true, }, routerOptions: { ignoreTrailingSlash: true, }, disableRequestLogging: true, // turn off logging logger: { level: “error” }, // except if server crashes }) .decorateRequest(“clientIp”, “”) // include new property in every Request instantiation .addHook(“onRequest”, (request, reply, done) => { request.clientIp = request.ip || request.raw.socket.remoteAddress || “127.0.0.1” done() }) .addHook(“onResponse”, (request, reply, done) => { // timestamp format 2026-06-15 13:58:07 const time = new Date().toISOString().replace(“T”, “ “).substring(0, 19) console.log(${time} [Fastify] | ${reply.statusCode} | ${request.clientIp} | ${request.method} ${request.url} HTTP/${request.raw.httpVersion}, ) done() }) // Routes follow .post(“/llmprompt”, handlers.llmprompt)// clean up on exit
// launch server
} catch (error) { console.error(error) process.exit(1) }
The line `.post("/llmprompt", handlers.llmprompt)` populates `Fastify`'s *routing table* to serve
the HTTP POST request with URL endpoint `/llmprompt`. We route the endpoint to the `llmprompt()`
function. Since we only allow HTTP POST request for the `/llmprompt` endpoint, we pass the
`llmprompt()` handler to `post()`
The function `llmprompt()` will be implemented in `handlers.ts` later.
We now register a callback function with the process: (1) to shut the process down gracefully when
terminated or interrupted by user, and (2) to print out a stack trace and exit with an error code in
the case of any uncaught exception. Replace the comment `// clean up on exit` with:
```ts
const cleanUp = (signal: string | Error, exitCode: number = 0) => {
app.close(async () => {
if (signal instanceof Error) {
console.error(`Uncaught exception: ${signal}\n` + `Stack trace: ${signal.stack}`)
}
process.exit(exitCode)
})
}
process.on('SIGINT', cleanUp)
process.on('SIGTERM', cleanUp)
process.on('uncaughtException', (err) => cleanUp(err, 2))
With process termination events taken care of, staying inside the try block, after setting up the
Fastify app, we launch up the node.js HTTP/2 server, binding it to the wildcard IP address
(0.0.0.0, equivalent to any) and the default HTTPS port (443). Replace // launch the server
with:
app.listen({ host: "0.0.0.0", port: 443 }, (err, addr) => {
if (err) {
console.error(err)
process.exit(1)
}
const address = app.server.address() as AddressInfo
console.log(`harnessd on https://${address.address}:${address.port}`)
})
We’re done with main.ts. Save and exit the file.
handlers.ts
We implement URL path API handlers in handlers.ts:
server$ vi handlers.ts
Start the file with the following imports:
import { type FastifyHttp2Request, type FastifyHttp2Reply } from './main.js'
import HttpStatus from 'http-status-codes'
import { Agent } from 'undici'
import { LlmRunners } from './runners.js'
We add a couple of logging functions that, in case of error, prepare a JSON response to be returned to the client:
const logOk = (request: FastifyHttp2Request, runner: string, model: string) => {
console.info(`runner: ${runner}:${model}`)
}
const logInfo = (request: FastifyHttp2Request, runner: string, model: string, errcode: number, msg: string) => {
console.info(`${errcode} |${request.ip} | ${runner}:${model} | ${msg}`)
}
const logClientErr = (request: FastifyHttp2Request, reply: FastifyHttp2Reply, runner: string, errcode: number, errmsg: string): FastifyHttp2Reply => {
console.warn(`${errcode} | ${request.ip} | ${runner} |`)
return reply.status(errcode).send(errmsg)
}
We instantiate a single undici.Agent dispatcher for use by fetch to connect to LLM runners. We
set the dispatcher’stimeout to 5 secs so that a non-responding runner will not stall our LLM runner
selector. We reuse keep a dispatcher alive after a connection is terminated so that we can reuse it
for the subsequent connection to the same destination, to skip connection setup time. We also
disable per-byte read deadline so that idle but incomplete SSE stream is not automatically ended.
// share a fetch dispatcher to the same destination to avoid
// subsequent connection establishment handshake
const dispatcher = new Agent({
connect: { timeout: 5_000 }, // dead destination
headersTimeout: 10_000, // stalled upstream
bodyTimeout: 0, // keep SSE streams open indefinitely
keepAliveTimeout: 60_000, // reuse socket for the same destination
})
We next define the handler llmprompt(), which first pick an LLM runner from a ranked list of
available runners. Since different runner provides different models, we patch the "model" field of
the incoming prompt to the model available at the runner. We use Node’s fetch() function to
connect to the LLM runner.
const runners = new LlmRunners()
export async function llmprompt(request: FastifyHttp2Request, reply: FastifyHttp2Reply) {
const reqBody = request.body as Record<string, any>
const body = { ...reqBody }
const model = body.model
for (const runner of runners) {
// patch the "model" field with the model available for this runner,
// if specified, else restore original model that came with request.
body.model = runner.model.length == 0 ? model: runner.model
// send prompt and return response
}
// no runner found
}
Then we assemble together a request addressed to the runner’s OpenAI-compatible API end point,
along with the preloaded API key for that runner, and send it to the LLM runner. If we fail to
connect to the LLM runner, we log the error and try the next runner on our list. If connection is
successful but the runner returns any kind of error, including being rate limited, we again log
the error and try the next runner on our list. If the runner completes the prompt, we log the
success and forward the completion to the user. Replace //send prompt and return response with:
const headers: Record<string, string> = {
"Content-Type": "application/json"
}
if (runner.apiKey) {
headers["Authorization"] = `Bearer ${runner.apiKey}`
}
try {
const response = await fetch(`${runner.baseUrl}/v1/chat/completions`, {
method: request.method,
headers,
body: JSON.stringify(body),
dispatcher,
} as RequestInit & { dispatcher: typeof dispatcher })
// If rate limited or server error, 'continue' naturally advances the iterator
if (!response.ok) {
logInfo(request, runner.baseUrl, runner.model, response.status, "Server error. Trying another one.")
if (response.body) {
// cleanup: drop the stream directly to prevent Node target connection leaks
await response.body.cancel()
}
continue
}
// Success! Break the loop and return the streaming response
logOk(request, runner.baseUrl, runner.model)
return reply
.status(response.status)
.header('Content-Type', response.headers.get('content-type') ?? 'text/event-stream')
.send(response.body) // Fastify natively streams standard ReadableStreams at max speed
} catch (error: any) {
// catch network/connection errors and failover
logInfo(request, runner.baseUrl, runner.model, HttpStatus.PROCESSING, `Connection error: ${error.message}. Trying another one.`)
continue
}
If we’ve exhausted all the runners on our list but couldn’t find any to complete the prompt, we log
the error and inform the client. Replace // no runner found with:
// If the for-loop naturally finishes without returning, we are exhausted.
return logClientErr(request, reply, "None", HttpStatus.TOO_MANY_REQUESTS, "All available LLM providers are rate limited or offline.")
We’re done with handlers.ts. Save and exit the file.
runners.ts
Your harness needs to connect to an LLM to perform prompt completion. This part of the tutorial connects the harness to an LLM runner of your choice. If you have a list of available LLM runners, we do a weighted-random round-robin selection to pick a runner to complete each incoming HTTP-request. You can also provide a locally provisioned LLM runner to fall back to in case no cloud-based runners are available. Once you set this feature up, we will be using it for the rest of the term but will not be spending more time on it.
Create runners.ts:
server$ vi runners.ts
First we define a struct, LlmRunner, to hold the base URL and API key of an LLM runner. LLM runners
each hosts a different set of models. Put the string that identifies the model you want to use to
the LLM runner in the model property. We will patch the "model" field of user’s prompt with this
string before sending it to this runner. If you want the option to run multiple models from the same
runner, create a separate LlmRunner for each model. The weight field records the priority you
want to give to this runner. The value of the weight should be in the range [0.0, 1.0], with 1.0
being the highest priority. If a model hosted by Runner A has a weight of 0.8 and two models
hosted by Runner B each has a weight of 0.5, you may see Runner B selected more
frequently in your trace. The weights don’t have to add up to 1.0. Add the following code to your file:
interface LlmRunner {
baseUrl: string
model: string
weight: number
apiKey: string | null
}
The weighted-random round-robin selection is implemented as the [Symbol.iterator]() method of an Iterable
interface. If the selected runner cannot complete the prompt, we move on to the next one on the list.
Failover runners are also selected in a weighted-random round-robin manner. Add the following
code to create an iterable list of LlmRunners:
export class LlmRunners implements Iterable<LlmRunner> {
private runners: LlmRunner[] = []
private local: LlmRunner | null
// The iterator that powers the `for (const runner of runners)` loop:
// to iterate through the runner array randomized per HTTP-request to load balance
// Implemented using the Schwartzian Transform (Decorate-Sort-Undecorate).
[Symbol.iterator](): Iterator<LlmRunner> {
// Calculate the Efraimidis and Spirakis A-Res scores based on weights
// "Weighted Random Sampling with a Reservoir", Information Processing Letters, 97:5, March 2006.
// Each runner gets a randomized score that, when sorted, determines
// the fallback order for each HTTP-request.
// Each HTTP-request gets a different ordered list, effecting the
// round-robin selection of runners across HTTP-requests.
const scored = this.runners.map(runner => {
const score = -Math.log(Math.random()) / (runner.weight > 0.0 ? runner.weight : 1e-9)
return { score, runner } // decorate
})
// Sort ascending by score.
scored.sort((a, b) => a.score - b.score)
// undecorate: extract just the ordered references and dynamically append the local fallback.
const sorted = scored.map(item => item.runner)
if (this.local) {
sorted.push(this.local)
}
return sorted.values()
}
// constructor()
}
Finally, constructor() initializes the list of runners. The code here lists LLM runners with free
tiers. You have to provide your own API key for each runner in an environment variable. If you have
subscription to other LLM runners (such as Claude or ChatGPT), you can add them to the list. If you have
provisioned for a local LLM as shown in llmprompt-backend, you can
assign it to the local variable as your ultimate fallback server. Replace // constructor() comment with:
constructor() {
this.local = null
// If you have provisioned a local llama-server, replace null above with:
//{ baseUrl: "http://localhost:11433", model: "", weight: 0.0, apiKey: null }
const apiKeys = {
cerebras: process.env.CEREBRAS_API_KEY || null,
groq: process.env.GROQ_API_KEY || null,
mistral: process.env.MISTRAL_API_KEY || null,
gemini: process.env.GEMINI_API_KEY || null,
google: process.env.GOOGLE_API_KEY || null,
nvidia: process.env.NVIDIA_API_KEY || null,
//openrouter: process.env.OPENROUTER_API_KEY || null,
}
const llmRunners: LlmRunner[] = [
{
baseUrl: "https://api.cerebras.ai",
apiKey: apiKeys.cerebras,
model: "zai-glm-4.7",
weight: 0.3
},
{
baseUrl: "https://api.groq.com/openai",
apiKey: apiKeys.groq,
model: "llama-3.1-8b-instant",
weight: 0.4
},
{
baseUrl: "https://api.groq.com/openai",
apiKey: apiKeys.groq,
model: "meta-llama/llama-4-scout-17b-16e-instruct",
weight: 0.4
},
{
baseUrl: "https://api.mistral.ai",
apiKey: apiKeys.mistral,
model: "ministral-8b-latest",
weight: 0.2
},
{
baseUrl: "https://api.mistral.ai",
apiKey: apiKeys.mistral,
model: "open-mistral-nemo",
weight: 0.2
},
{
baseUrl: "https://integrate.api.nvidia.com",
apiKey: apiKeys.nvidia,
model: "meta/llama-3.1-8b-instruct",
weight: 0.2
},
{
baseUrl: "https://integrate.api.nvidia.com",
apiKey: apiKeys.nvidia,
model: "deepseek-ai/deepseek-v4-flash",
weight: 0.2
},
{
baseUrl: "https://integrate.api.nvidia.com",
apiKey: apiKeys.nvidia,
model: "nvidia/nemotron-nano-12b-v2-vl",
weight: 0.3
},
/*
* Don't include Open Router in swap_remove() reshuffling.
* If added, should add it as LlmRunners.fallback to try
* as last resort before local llm, but after all the others,
* due to its low rate limit and variable model availability
{
baseUrl: "https://openrouter.ai/api",
apiKey: apiKeys.openrouter,
model: "openrouter/free", // whichever best model is available and least loaded
weight: 0.1
},
*/
]
// loop to filter out missing API Key and push
for (const runner of llmRunners) {
if (runner.apiKey) {
console.error(`Runner ${runner.baseUrl} ACTIVE.`)
this.runners.push(runner)
} else {
console.error(`Runner ${runner.baseUrl} INACTIVE. No API KEY.`)
}
}
if (this.runners.length === 0 && !this.local) {
console.error("CRITICAL: No API keys found and no local LLM configured. Terminating process.")
process.exit(1)
}
}
We’re done with runners.ts. Save and exit the file.
Build and Test run
To run, TypeScript must first be transpiled into JavaScript. This means you must run pnpm build each and every time you made changes to your code, for the changes to show up when you run node.
To build your server, transpile TypeScript into JavaScript:
server$ pnpm build
You should now see a harnessd.js file in the directory.
Storing API keys
When we have only one or two environment variables to set, we can do it on the
command-line interface (CLI) right before running harnessd, e.g.,
server$ sudo LLM_RUNNER_API_KEY=YOUR_API_KEY node harnessd.js
When you have multiple API keys, however, this becomes impractical. A not very secure option is
to store your API keys in /root/.bashrc or /root/.zshrc (
make sure you don’t push
this file to your git repo!):
server$ sudo su
# You are now root, note the command-line prompt changed from '$' to '#'.
# You can do a lot of harm with root privileges, so be very careful what you do here.
server# vi /root/.bashrc
Add these to the end of the file and provide your available API keys:
export CEREBRAS_API_KEY=""
export GROQ_API_KEY=""
export GEMINI_API_KEY=""
export MISTRAL_API_KEY=""
export NVIDIA_API_KEY=""
export OPENROUTER_API_KEY=""
export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libmimalloc.so
export MIMALLOC_ARENA_RESERVE=320MiB
export NODE_ENV=production
Note that we have also added an environment variable to point to the mimalloc memory allocator we’re using with harnessd. Mimalloc uses memory more efficiently and supports higher concurrency than either the V8 or Linux malloc or jemalloc, especially for harness architecture workload. After you add these environment variables to your /root/.bashrc, you’d have to do:
server# source /root/.bashrc
to load them into your current shell. Subsequent sudo su will load them automatically.
Then you can just run your harnessd:
server# node harnessd.js # NOTE `.js` not `.ts`
# Hit ^C to end the test
server# exit
# So that you're no longer root.
server$
`dotenv`
The dotenv library is often used to read .env file. However, common container,
serverless, and managed runtime platforms (such as AWS ECS/Fargate and Lambda, Cloudflare Workers,
Heroku, Kubernetes, Vercel, etc.), inject configurations directly into process memory, bypassing
physical files. We thus import environment variables directly from the parent (shell) process
when you run harnessd from the CLI or access them indirectly through
systemd service configuration file.
Now return to the back-end cover page to complete a TODO item
and test your implementation following the instructions in the subsequent “Testing llmPrompt APIs”
section. You must complete ithe TODO to get full credit for this tutorial.
References
- 7 Simple Steps to build your own TypeScript Node.js project
- A Complete Guide to HTTP/2 in Node.js
- Mastering Error Handling in JavaScript
- Production best practices: performance and reliability
- The Basics of Node.js Streams
| Prepared by Chenglin Li, Xin Jie ‘Joyce’ Liu, Sugih Jamin | Last updated June 23rd, 2026 |