homeresume
 
   
🔍

Nginx local setup

June 21, 2026

Nginx is a reverse proxy and web server. On a local machine it can sit on port 80 and route traffic to apps running on other ports - a frontend on :3000, an API on :4000, or a Docker Compose stack with published ports.

This post covers install, config layout, reverse proxying, path-based routes, static files, and service commands on a local dev machine. It does not cover HTTPS, production hardening, or multi-domain TLS.

On Windows, use WSL2 with Ubuntu. On macOS or Linux, the same apt or brew install steps apply.

Prerequisites

  • WSL2 with Ubuntu (Windows) or a Linux/macOS shell
  • At least one app listening on a local port (for example :3000)
  • sudo access to install packages and edit /etc/nginx

Mental model

  • server - a virtual host block (listen, server_name) that handles requests for a hostname.
  • location - a path prefix inside a server (/, /api/) matched against the request URI.
  • proxy_pass - forwards the request to another HTTP server (your Node app, Compose service, etc.).
  • root - serves files from a directory on disk (built SPA, static HTML).

On Debian and Ubuntu, site configs live in /etc/nginx/sites-available/ and are enabled with symlinks in /etc/nginx/sites-enabled/. The main file /etc/nginx/nginx.conf includes those enabled sites.

Install

sudo apt update
sudo apt install nginx

Start Nginx and verify the default page:

sudo systemctl start nginx
curl -I http://localhost

You should get an HTTP 200 or 301 response.

Config layout

PathPurpose
/etc/nginx/nginx.confMain config; includes enabled sites
/etc/nginx/sites-available/Site config files (one file per app or domain)
/etc/nginx/sites-enabled/Symlinks to enabled sites
/var/log/nginx/access.logRequest log
/var/log/nginx/error.logErrors and config issues

Create a new site file:

sudo nano /etc/nginx/sites-available/local-dev

Enable it and disable the default site if it conflicts on port 80:

sudo ln -s /etc/nginx/sites-available/local-dev /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

Always run nginx -t before reloading. A syntax error blocks reload and leaves the old config in place.

Proxy one service

Forward all traffic to an app on port 3000:

server {
listen 80;
server_name localhost;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

The Upgrade and Connection headers allow WebSocket connections through the proxy (useful for Vite, Next.js dev server, etc.).

Use an upstream block when several backend servers share the same target name; for a single local app, proxy_pass with a URL is enough.

Path-based routes

Route /api/ to a backend on port 4000 and everything else to a frontend on port 3000:

server {
listen 80;
server_name localhost;
location /api/ {
proxy_pass http://127.0.0.1:4000/;
proxy_set_header Host $host;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
}
}

Trailing slash in proxy_pass - proxy_pass http://127.0.0.1:4000/; (note the slash after the port) strips the matched /api/ prefix before forwarding. A request to /api/health reaches the backend as /health. Without the trailing slash, the backend receives /api/health.

Longer prefixes should use more specific location blocks. Nginx picks the best match for the request URI.

Static files

Serve a built frontend from disk instead of proxying:

server {
listen 80;
server_name localhost;
root /var/www/my-app;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

try_files falls back to index.html for client-side routes in SPAs.

Commands

CommandPurpose
sudo systemctl start nginxStart Nginx
sudo systemctl stop nginxStop Nginx
sudo systemctl restart nginxFull restart (drops active connections)
sudo systemctl reload nginxReload config without dropping connections
sudo systemctl status nginxCheck service state
sudo nginx -tValidate config syntax
sudo nginx -s reloadReload without systemd
curl -I http://localhostSmoke test from the shell

Safe edit workflow: edit config → sudo nginx -tsudo systemctl reload nginx.

Use reload after config changes when the syntax test passes. Use restart when Nginx fails to start or after package upgrades.

Local networking on Windows + WSL

Nginx runs inside WSL. 127.0.0.1 inside WSL refers to the Linux VM, not processes on the Windows host.

  • App runs inside WSL or Docker Compose - use http://127.0.0.1:<port> in proxy_pass when the port is published to the WSL host (Compose ports: mapping works).

  • App runs on Windows only - use the Windows host IP from WSL:

    grep nameserver /etc/resolv.conf | awk '{ print $2 }'

    Put that IP in proxy_pass instead of 127.0.0.1, or run the app inside WSL/Compose so localhost routing stays simple.

From a Windows browser, http://localhost reaches Nginx in WSL when it listens on port 80.

Troubleshooting

  • Port 80 already in use - check what holds the port: sudo ss -tlnp | grep :80. Stop the conflicting service or change Nginx listen to another port (for example 8080).
  • 502 Bad Gateway - the upstream app is not running or the proxy_pass URL is wrong. Confirm the app responds: curl http://127.0.0.1:3000.
  • Config test fails - read the path and line from sudo nginx -t output; fix the file and test again before reload.
  • Unexpected routing - check /var/log/nginx/error.log and /var/log/nginx/access.log.
  • Docker Compose overview - run frontend and API in Compose, then point Nginx at the published host ports

Demo

Runnable configs for this post live in the nginx-local-setup-demo folder. Get access via code demos.

Docker Compose overview

June 20, 2026

Docker Compose runs multi-container applications from a single YAML file. One command can start an API, a database, a message broker, and supporting tools for local development - without installing each service on the host machine.

This post covers Compose concepts and commands. For ready-made stacks, see the service-specific posts linked at the end (Postgres/Redis, RabbitMQ, MongoDB, Kafka, DynamoDB/SQS).

Prerequisites

  • Docker Engine installed
  • Compose V2 - use docker compose (with a space). Current Docker Desktop includes it; no separate Compose install is required.

Mental model

  • Project - the folder that contains docker-compose.yml. The project name defaults to the directory name and prefixes container names.
  • Services - named containers defined in the file (api, redis, postgres). Each service maps to one image or build context.
  • Network - Compose creates a default network so services resolve each other by name. From the api container, Redis is reachable at redis:6379, not localhost:6379.
  • Volumes - named or bind mounts for data that survives docker compose down (unless you pass -v).

Minimal compose file

A two-service stack: a Node API and Redis. No top-level version: key - it is deprecated in the current Compose specification.

services:
api:
build: .
ports:
- 3000:3000
environment:
REDIS_URL: redis://redis:6379
depends_on:
- redis
redis:
image: redis:alpine
volumes:
- redis-data:/data
volumes:
redis-data:

Run docker compose up --build from the directory that contains this file.

Core concepts

Ports - map host ports to container ports as host:container:

ports:
- 3000:3000

Environment - inline variables or an env file:

environment:
REDIS_URL: redis://redis:6379
env_file:
- .env

Volumes - named volumes are managed by Docker (good for database data). Bind mounts map a host path into the container (good for live code reload during development):

volumes:
- redis-data:/data # named
- ./src:/app/src:ro # bind mount

Networks - services on the default network can reach each other by service name. Custom networks isolate groups of services (see the Postgres and Redis post for a multi-network example).

depends_on - controls startup order. It does not wait for the dependency to be ready; add a healthcheck or retry logic in the app when you need readiness.

restart - policies like on-failure:3 or unless-stopped keep containers running after crashes or host reboots.

healthcheck - optional probe so Compose and other services know when a container is ready:

healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 5

Commands

CommandPurpose
docker compose upStart services (foreground, logs in terminal)
docker compose up -dStart in detached mode
docker compose up --buildRebuild images before starting
docker compose downStop and remove containers
docker compose down -vAlso remove named volumes
docker compose psList running services
docker compose logs -f apiFollow logs for one service
docker compose exec api shOpen a shell in a running container
docker compose pullPull latest images

When to use what

ToolBest for
docker runOne-off containers, quick image tests
Docker ComposeLocal multi-service stacks, dev databases and queues
NginxLocal reverse proxy on port 80 in front of Compose or dev servers - see Nginx local setup
KubernetesProduction orchestration, scaling, rolling deploys

Service-specific setups

Demo

Runnable files for this post live in the docker-compose-overview-demo folder. Get access via code demos.

Git SSH keys setup

June 19, 2026

SSH keys let you authenticate with Git hosts without entering a password on every push or pull. This post covers generating keys for multiple providers, loading them into the SSH agent, and cloning repositories over SSH.

For general Git commands, see the Git cheatsheet.

Generate SSH keys

Generate a separate key for each provider (for example, GitHub and Bitbucket). Run ssh-keygen for each key, set a distinct filename, and optionally add a passphrase.

ssh-keygen -f ~/.ssh/id_rsa_github
ssh-keygen -f ~/.ssh/id_rsa_bitbucket

Add public keys to providers

Copy each public key (the .pub file) and add it to the provider:

cat ~/.ssh/id_rsa_github.pub
cat ~/.ssh/id_rsa_bitbucket.pub

Activate SSH keys

Start the SSH agent and add the keys you need for the current session.

eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_rsa_github
ssh-add ~/.ssh/id_rsa_bitbucket

Verify the connection to a provider:

ssh -T git@github.com
ssh -T git@bitbucket.org

Shell aliases per provider

Add aliases to ~/.bashrc or ~/.zshrc to run the agent setup and load the correct key in one command.

alias ssh-github='eval "$(ssh-agent -s)" && ssh-add ~/.ssh/id_rsa_github'
alias ssh-bitbucket='eval "$(ssh-agent -s)" && ssh-add ~/.ssh/id_rsa_bitbucket'

Reload the shell configuration, then run the alias before working with that provider:

source ~/.zshrc
ssh-github

Clone repositories

Use the SSH remote URL when cloning. The host (github.com or bitbucket.org) determines which key the provider expects.

# git clone <REPOSITORY_URL>
git clone git@github.com:workspace/repo-name.git
git clone git@bitbucket.org:workspace/repo-name.git

If authentication fails, run the matching alias (for example, ssh-github) and try again.

Conversation memory for LangChain agents

June 18, 2026

This post extends the support triage agent from Building AI agents with LangChain into a multi-turn flow: turn 1 looks up the customer and invoice; turn 2 creates the ticket without the user repeating IDs. It is post #5 in the LangChain series, following the overview, loaders/chunking, RAG, and agents posts.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • Packages from the agents post, plus the checkpoint package:
npm i langchain @langchain/openai @langchain/core @langchain/langgraph-checkpoint zod
  • OPENAI_API_KEY set in the environment

Mental model

Three related concepts:

  • Checkpointer - short-term session memory. Saves messages and graph state after each step so the next invoke on the same thread can resume.
  • thread_id - conversation key passed in configurable. Same ID = same history; different ID = isolated session.
  • Store - long-term memory across threads (user preferences, facts learned over time). LangGraph stores are separate from checkpointers; this post focuses on checkpointers only.

Typical support flow with memory:

  1. Turn 1 - rep asks to look up cus_1042 and inv_8891; agent calls lookup tools and summarizes findings.
  2. Turn 2 - rep says "create the ticket we discussed"; agent recalls prior tool results and calls create_support_ticket.

MemorySaver

For demos and tests, use MemorySaver - an in-memory checkpointer that persists state for the lifetime of the process:

import { MemorySaver } from '@langchain/langgraph-checkpoint';
const checkpointer = new MemorySaver();

State is lost when the Node process exits. That is fine for local scripts; production apps need a durable backend (see below).

Attach a checkpointer to createAgent

Pass the checkpointer when creating the agent. Reuse the same triage tools and instructions from the agents post:

import { createAgent } from 'langchain';
import { MemorySaver } from '@langchain/langgraph-checkpoint';
const agent = createAgent({
model: 'gpt-5.5',
tools: supportTools,
systemPrompt: TRIAGE_INSTRUCTIONS,
checkpointer: new MemorySaver(),
});

The agent loop is unchanged - the checkpointer hooks into LangGraph beneath createAgent.

First turn - lookup

Pass a stable thread_id in the invoke config:

const threadConfig = { configurable: { thread_id: 'support-cus-1042' } };
const turn1 = await agent.invoke(
{
messages: [
{
role: 'user',
content:
'Look up customer cus_1042 and invoice inv_8891 for a possible duplicate charge. Summarize what you find. Do not create a ticket yet.',
},
],
},
threadConfig,
);
console.log(turn1.messages.at(-1)?.content);

The agent calls get_customer, get_invoice, and search_knowledge_base. LangGraph saves the full message history (including tool results) to the checkpointer.

Second turn - follow-up without IDs

Send only the new user message on the same thread_id. Prior context is restored automatically:

const turn2 = await agent.invoke(
{
messages: [
{
role: 'user',
content: 'Create the support ticket we discussed.',
},
],
},
threadConfig,
);
console.log(turn2.messages.at(-1)?.content);

The agent should call create_support_ticket using customer and invoice details from turn 1 - the user does not repeat cus_1042 or inv_8891.

Read the final answer from result.messages as in the agents post:

const lastAi = [...turn2.messages]
.reverse()
.find((message) => message.type === 'ai');
console.log(lastAi?.content);

Thread isolation

Different thread_id values do not share history. Two support reps working different cases should use separate thread IDs:

await agent.invoke(
{ messages: [{ role: 'user', content: 'Look up cus_1042.' }] },
{ configurable: { thread_id: 'rep-alice-case-1' } },
);
await agent.invoke(
{ messages: [{ role: 'user', content: 'Create the ticket we discussed.' }] },
{ configurable: { thread_id: 'rep-bob-case-2' } },
);

The second invoke on rep-bob-case-2 has no knowledge of Alice's lookup - Bob's thread starts empty.

Production checkpointers

MemorySaver is process-local and not suitable for production. LangGraph supports durable checkpointers backed by Postgres, SQLite, and other stores via @langchain/langgraph-checkpoint integrations. Swap the checkpointer implementation; the thread_id API stays the same.

Pick a backend that matches your deployment: Postgres for multi-instance apps, SQLite for single-node services.

Demo

See the langchain-agent-memory-nodejs-demo folder for multi-turn triage and thread-isolation scripts.

Building AI agents with LangChain

June 17, 2026

LangChain agents are built on LangGraph: the model calls tools in a loop until it returns a final answer. The high-level entry point is createAgent - pass a model, tools defined with tool(), and an optional systemPrompt.

This post builds the same support triage agent as the Vercel AI SDK agents and OpenAI Agents SDK posts so you can compare SDKs on one scenario. It follows the LangChain overview for Node.js and fits as post #4 in the LangChain series (after loaders/chunking and the RAG with pgvector pipeline).

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • langchain, @langchain/openai, @langchain/core, and zod installed:
npm i langchain @langchain/openai @langchain/core zod
  • OPENAI_API_KEY set in the environment

Mental model - turns and the agent loop

A turn is one model generation. In that turn the model either:

  • returns final text (the run ends), or
  • returns tool calls (LangChain executes them and starts another turn with the results)

Typical flow for the support triage agent: user question → model calls lookup tools (get_customer, get_invoice, search_knowledge_base) → model creates a ticket or escalates → final answer.

A single turn can include multiple parallel tool calls. Set recursionLimit on invoke or stream to cap how many graph steps run (each model generation and tool batch counts toward the limit).

Defining tools

Use tool() from langchain with a Zod schema, plus name and description so the model knows when to call each tool:

import { tool } from 'langchain';
import { z } from 'zod';
const getInvoice = tool(
async ({ invoiceId }) => {
const invoice = invoices.find((item) => item.id === invoiceId);
if (!invoice) {
return { found: false, invoiceId, error: 'Invoice not found' };
}
return { found: true, invoice };
},
{
name: 'get_invoice',
description: 'Look up an invoice by ID, including payment IDs and status',
schema: z.object({
invoiceId: z.string().describe('Invoice ID, e.g. inv_8891'),
}),
},
);

LangChain uses schema (not Vercel's inputSchema or OpenAI Agents' parameters). The handler receives validated input as the first argument.

createAgent

Wire the model, tools, and triage instructions:

import { createAgent } from 'langchain';
const agent = createAgent({
model: 'gpt-5.5',
tools: [getInvoice],
systemPrompt: `You are a billing support triage agent.
Look up records before recommending refunds or creating tickets.`,
});

model can be a provider string ('gpt-5.5', 'openai:gpt-5.5') or a chat model instance from @langchain/openai.

Invoke

Pass a messages array and read the final answer from result.messages:

const result = await agent.invoke({
messages: [
{
role: 'user',
content: 'What is the status of invoice inv_8891? Reply in one sentence.',
},
],
});
const lastAi = [...result.messages]
.reverse()
.find((message) => message.type === 'ai');
console.log(lastAi?.content);

The last AI message is the agent's final reply after any tool calls complete.

Support triage scenario

Example prompt:

Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?

A realistic chain:

  1. get_customer - plan tier, open ticket count
  2. get_invoice - amount, status, payment IDs
  3. search_knowledge_base - duplicate-charge and refund policy
  4. create_support_ticket or escalate_to_human - write action or escalation

The demo uses in-memory fixtures (customers, invoices, knowledge-base articles) so scripts run without a database.

Multi-tool agent

Register all triage tools on one agent:

import { createAgent } from 'langchain';
import {
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
TRIAGE_INSTRUCTIONS,
} from './tools/index.js';
const agent = createAgent({
model: 'gpt-5.5',
tools: [
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
],
systemPrompt: TRIAGE_INSTRUCTIONS,
});
const result = await agent.invoke({
messages: [
{
role: 'user',
content:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
},
],
recursionLimit: 15,
});
const answer = [...result.messages]
.reverse()
.find((message) => message.type === 'ai');
console.log(answer?.content);

Inspect result.messages for the full trace: human input, AI tool-call messages, tool results, and the final AI reply.

Streaming

agent.stream() yields state updates as the graph runs. Use streamMode: 'values' to receive the full message list after each step:

const stream = await agent.stream(
{
messages: [
{
role: 'user',
content:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
},
],
},
{ streamMode: 'values', recursionLimit: 15 },
);
let finalMessages = [];
for await (const state of stream) {
if (state.messages) {
finalMessages = state.messages;
}
}
const answer = [...finalMessages]
.reverse()
.find((message) => message.type === 'ai');
console.log(answer?.content);

For token-level streaming, use streamMode: 'messages' or streamEvents (see LangGraph streaming).

When to pick LangChain

LangChain createAgentVercel AI SDKOpenAI Agents SDK
Best forRAG + LCEL + agents in one stackTypeScript apps already on AI SDKOpenAI-first agent primitives
Tool definitiontool() + Zod schematool() + inputSchematool() + Zod parameters
Run APIagent.invoke / agent.streamgenerateText + stopWhenrun() + maxTurns
Handoffs / guardrailsMiddleware (advanced)LimitedBuilt-in
MemoryLangGraph checkpointersBring your ownSession helpers

Pick LangChain when document loaders, retrievers, and agents should share one ecosystem. Pick Vercel AI SDK or OpenAI Agents SDK when you want a focused agent layer without the broader LangChain surface.

Demo

See the langchain-agents-nodejs-demo folder for runnable scripts: single-tool lookup, full triage, and streaming.

Document loaders and chunking with LangChain

June 16, 2026

This post covers local file ingestion and chunking in Node.js. For LangChain basics (LCEL, packages, agents), see the LangChain overview post. For the full RAG chain with pgvector, see the RAG with pgvector post.

Prerequisites

  • Node.js version 26
  • langchain, @langchain/core, @langchain/classic, and @langchain/textsplitters installed
npm i langchain @langchain/core @langchain/classic @langchain/textsplitters

More loader types (web, cloud, audio) live in standalone integration packages - see the document loader integrations page.

The Document type

Every loader returns Document instances from @langchain/core:

  • pageContent - the text of the chunk or file
  • metadata - optional key/value pairs (source path, section, page) used for citations
import { Document } from '@langchain/core/documents';
const doc = new Document({
pageContent: 'pgvector adds vector search to PostgreSQL.',
metadata: { source: 'notes/pgvector.txt', section: 'basics' }
});

Load a single file

Use TextLoader for plain text or markdown files:

import { TextLoader } from '@langchain/classic/document_loaders/fs/text';
const loader = new TextLoader('./notes/pgvector.txt');
const docs = await loader.load();
console.log(docs[0].pageContent);
console.log(docs[0].metadata.source);

The loader sets metadata.source to the file path - keep it for citations in RAG answers.

Load a directory

Use DirectoryLoader when you have many files. Map extensions to loader factories:

import { DirectoryLoader } from '@langchain/classic/document_loaders/fs/directory';
import { TextLoader } from '@langchain/classic/document_loaders/fs/text';
const loader = new DirectoryLoader('./notes', {
'.txt': (path) => new TextLoader(path),
'.md': (path) => new TextLoader(path)
});
const docs = await loader.load();
console.log(`Loaded ${docs.length} documents`);

PDF, CSV, and JSON loaders are available via other integration packages. This post uses .txt and .md files.

Split documents

Chunking makes retrieval more precise. Instead of embedding one large file, split it into smaller overlapping parts. Pass the docs array from TextLoader or DirectoryLoader to a splitter:

Two parameters matter most:

  • chunkSize - target maximum size per chunk (characters or tokens, depending on splitter)
  • chunkOverlap - shared text between adjacent chunks so context is not lost at boundaries

Start with chunkSize: 800 and chunkOverlap: 120, then tune based on document style and answer quality.

import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 800,
chunkOverlap: 120
});
const chunks = await splitter.splitDocuments(docs);
console.log(chunks.length);

Splitter comparison

The example above uses RecursiveCharacterTextSplitter, the default for most RAG setups. Alternatives:

SplitterBest for
RecursiveCharacterTextSplitterDefault choice; tries paragraphs, then sentences, then words
CharacterTextSplitterFixed character windows when structure does not matter
TokenTextSplitterWhen chunk limits must match model token budgets

Character-based:

import { CharacterTextSplitter } from '@langchain/textsplitters';
const splitter = new CharacterTextSplitter({
chunkSize: 800,
chunkOverlap: 120
});
const chunks = await splitter.splitDocuments(docs);

Token-based:

import { TokenTextSplitter } from '@langchain/textsplitters';
const splitter = new TokenTextSplitter({
encodingName: 'cl100k_base',
chunkSize: 200,
chunkOverlap: 20
});
const chunks = await splitter.splitDocuments(docs);

Use token-based splitting when chunks must fit within a model's context window. Character-based recursive splitting is the usual starting point for RAG over prose.

Metadata through the pipeline

Pass metadata when creating documents manually, or rely on loader metadata - splitters preserve it on each chunk:

const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
chunkOverlap: 60
});
const chunks = await splitter.createDocuments(
['First paragraph.\n\nSecond paragraph.'],
[{ source: 'manual', section: 'intro' }]
);
console.log(chunks[0].metadata);

After splitDocuments(docs), each chunk keeps fields like source from the parent document. Use those fields when storing chunks in a vector database or displaying citations.

Choosing parameters

  • Short FAQs or API docs - smaller chunkSize (300–500) for precise retrieval
  • Long guides or blog posts - larger chunkSize (800–1200) to keep sections together
  • More overlap - helps when answers span chunk boundaries; increases storage and embedding cost
  • Less overlap - fewer redundant chunks; risk losing context at splits

Tune with real questions from your domain.

Demo

Runnable loader and splitter scripts for this post live in the langchain-loaders-chunking-demo folder. Get access via code demos.

LangChain overview for Node.js

June 15, 2026

LangChain.js is a framework for LLM applications in TypeScript and Node.js. It standardizes how you wire prompts, models, tools, document loaders, embeddings, and retrievers into reusable pipelines and agents.

LangChain, Deep Agents, LangGraph, and LangSmith

ProjectRole
LangChainHigh-level APIs: LCEL chains, createAgent, loaders, retrievers
Deep AgentsBatteries-included agent harness: planning, subagents, filesystem, context management
LangGraphLow-level orchestration; LangChain agents run on LangGraph under the hood
LangSmithTracing, debugging, and evaluation for LangChain and LangGraph apps

Use Deep Agents for complex multi-step tasks out of the box. Use LangChain's createAgent when you want a minimal harness you compose with middleware. Reach for LangGraph when you need custom stateful workflows, branching, or fine-grained control over the agent loop.

Packages

Install the core packages first (install guide):

npm i langchain @langchain/core @langchain/openai zod

Provider-specific integrations live in separate packages:

  • langchain - createAgent, tool, and high-level chain helpers
  • zod - tool input schemas when defining tools with tool()
  • @langchain/core - prompts, output parsers, Runnable interface, LCEL
  • @langchain/openai - ChatOpenAI, OpenAIEmbeddings
  • @langchain/textsplitters - document chunking (used in the RAG post)
  • Standalone integration packages for other providers and tools (see the integrations page)

For raw API access, see the Chat Completions and OpenAI Responses API posts. For provider-agnostic text and agents, see the Vercel AI SDK and OpenAI Agents SDK posts.

When to use LangChain

ToolBest for
Raw openai packageMinimal calls, full control, least abstraction
Vercel AI SDKProvider-agnostic generateText, streaming, embeddings, tool loops
OpenAI Agents SDKOfficial agent loop, handoffs, guardrails
LangChainDocument ingestion, retrievers, LCEL chains, createAgent, swappable vector stores

Reach for LangChain when RAG or multi-step LLM pipelines grow beyond a few manual API calls.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • langchain, @langchain/core, @langchain/openai, and zod installed
  • OPENAI_API_KEY set in the environment

Core concepts

Document - a chunk of text with optional metadata. Loaders produce Document instances; splitters break long sources into retrieval-friendly pieces.

import { Document } from '@langchain/core/documents';
const doc = new Document({
pageContent: 'LangChain helps compose LLM pipelines.',
metadata: { source: 'intro' }
});

Runnable - any component with .invoke(), .stream(), or .batch(). Prompts, models, parsers, and composed chains are all Runnables.

LCEL (LangChain Expression Language) - chain Runnables with .pipe(). Data flows left to right: prompt → model → parser. The same .invoke(), .stream(), and .batch() interface applies to every Runnable in the chain.

import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { ChatOpenAI } from '@langchain/openai';
const prompt = ChatPromptTemplate.fromMessages([
['system', 'Answer in one sentence.'],
['human', '{question}']
]);
const model = new ChatOpenAI({ model: 'gpt-5.5' });
const chain = prompt.pipe(model).pipe(new StringOutputParser());
const answer = await chain.invoke({ question: 'What is LangChain?' });
console.log(answer);

Agents - LangChain's current high-level agent API is createAgent. Pass a model string or chat model, optional tools (with zod schemas), and an optional checkpointer for conversation memory (@langchain/langgraph). For tools and the support triage scenario, see the agents post.

import { createAgent } from 'langchain';
const agent = createAgent({
model: 'gpt-5.5',
tools: []
});
const result = await agent.invoke({
messages: [{ role: 'user', content: 'What is LangChain?' }]
});

Structured output - return typed JSON instead of free text. In LCEL chains, call .withStructuredOutput() on a chat model with a Zod schema:

import { z } from 'zod';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { ChatOpenAI } from '@langchain/openai';
const schema = z.object({
answer: z.string(),
confidence: z.number(),
});
const prompt = ChatPromptTemplate.fromMessages([
['system', 'Answer briefly and rate your confidence from 0 to 1.'],
['human', '{question}'],
]);
const model = new ChatOpenAI({ model: 'gpt-5.5' }).withStructuredOutput(schema);
const result = await prompt.pipe(model).invoke({ question: 'What is LangChain?' });
console.log(result);

On agents, pass the same schema as responseFormat and read result.structuredResponse:

import { createAgent } from 'langchain';
import { z } from 'zod';
const schema = z.object({ answer: z.string(), confidence: z.number() });
const agent = createAgent({
model: 'gpt-5.5',
tools: [],
responseFormat: schema,
});
const result = await agent.invoke({
messages: [{ role: 'user', content: 'What is LangChain?' }],
});
console.log(result.structuredResponse);

What LangChain can do

  • Load and split documents - file and directory loaders, text splitters (see the loaders and chunking post); PDF, HTML, CSV via integration packages
  • Embeddings and vector stores - OpenAI embeddings with pgvector, Pinecone, Chroma, and others
  • Retrievers and RAG chains - fetch relevant context, then call a model (see the RAG with pgvector post)
  • Conversation memory - short-term memory via @langchain/langgraph checkpointers and thread_id (see the agent memory post); long-term memory via stores
  • Tools and agents - createAgent with tools and middleware; for production agents you may also prefer the Vercel AI SDK agents post or OpenAI Agents SDK post
  • Structured output - Zod schemas via .withStructuredOutput() on a chat model or responseFormat on createAgent; read parsed objects from the chain result or result.structuredResponse
  • Observability - trace runs with LangSmith (LANGSMITH_TRACING=true); optional LangSmith Engine monitors traces and flags issues

Streaming and batch

The same LCEL chain supports streaming and batch invocation:

for await (const chunk of await chain.stream({ question: 'What is LCEL?' })) {
process.stdout.write(chunk);
}
const answers = await chain.batch([
{ question: 'What is a Runnable?' },
{ question: 'What is a retriever?' }
]);

Demo

Runnable LCEL scripts for this post live in the langchain-overview-nodejs-demo folder. Get access via code demos.

Node Version Manager (nvm) for Windows

June 14, 2026

On Windows, nvm-windows manages multiple Node.js versions. It is a separate project from nvm-sh used on macOS and Linux - see the nvm overview post for that setup.

As of mid-2026, Node.js 24 is Active LTS, 22 is Maintenance LTS, and 26 is the Current release. nvm-windows 1.2.2 is the latest release.

Uninstall any existing Node.js installation before installing nvm-windows to avoid PATH conflicts.

Installation

Download and run nvm-setup.exe from the nvm-windows releases page.

Alternatively, install with a package manager:

winget install CoreyButler.NVMforWindows
choco install nvm

Open a new terminal and verify the installation:

nvm version

Version management

  • Install a specific version

    nvm install 24.16.0
  • Install the latest Current release

    nvm install latest
  • Install the latest LTS release

    nvm install lts
  • Install the latest patch for a major version

    nvm install 24
  • Switch to an installed version

    nvm use 24.16.0
  • Add a .nvmrc file inside the project directory and run nvm use to activate the version it specifies

    24.16.0
  • List locally installed versions

    nvm list
  • List versions available for installation

    nvm list available
  • Show the active Node version

    nvm current

Differences from nvm-sh

Featurenvm-windowsnvm-sh (macOS/Linux)
Version format1.2.20.40.5
Install latestnvm install latestnvm install node
Install LTSnvm install ltsnvm install --lts
List remote versionsnvm list availablenvm ls-remote
List local versionsnvm list or nvm lsnvm ls

DynamoDB and SQS containers with Docker Compose

June 13, 2026

For Compose basics, see the Docker Compose overview. This post covers DynamoDB Local and ElasticMQ (SQS-compatible) for local AWS-style development.

Prerequisites

  • Docker Compose installed

Configuration

The following configuration spins up DynamoDB Local, DynamoDB Admin, ElasticMQ, and the ElasticMQ UI.

Connection details:

  • DynamoDB endpoint: http://localhost:8000
  • SQS endpoint: http://localhost:9324
  • DynamoDB Admin: http://localhost:8001
  • ElasticMQ UI: http://localhost:3000
  • Region: us-east-1
  • Credentials: any dummy values (for example local / local)
# docker-compose.yml
services:
dynamodb:
image: amazon/dynamodb-local:latest
user: root
command: '-jar DynamoDBLocal.jar -sharedDb -dbPath /home/dynamodblocal/data'
ports:
- '8000:8000'
volumes:
- dynamodb_data:/home/dynamodblocal/data
dynamodb-admin:
image: aaronshaf/dynamodb-admin:latest
ports:
- '8001:8001'
environment:
DYNAMO_ENDPOINT: http://dynamodb:8000
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: local
AWS_SECRET_ACCESS_KEY: local
depends_on:
- dynamodb
elasticmq:
image: softwaremill/elasticmq-native:latest
ports:
- '9324:9324'
volumes:
- ./elasticmq.conf:/opt/elasticmq.conf
- elasticmq_data:/data
elasticmq-ui:
image: softwaremill/elasticmq-ui:latest
ports:
- '3000:3000'
environment:
SQS_ENDPOINT: http://elasticmq:9324
depends_on:
- elasticmq
volumes:
dynamodb_data:
elasticmq_data:

ElasticMQ loads queue definitions from an elasticmq.conf file included in the demo.

Run the following command to spin up the containers.

docker compose up -d

Disclaimer

ElasticMQ provides an SQS-compatible API, but it is not AWS SQS. DynamoDB Local also differs from managed DynamoDB (for example capacity modes, streams, TTL, and global tables). Use these containers for local development and integration tests, not as production substitutes.

Demo

Docker Compose files and scripts for this post live in the dynamodb-sqs-docker-compose folder. Get access via code demos.

Building AI agents with OpenAI Agents SDK

June 12, 2026

The OpenAI Agents SDK (@openai/agents) is OpenAI's official framework for agentic apps in TypeScript. It provides a small set of primitives: Agent, tools, handoffs, guardrails, and a run loop managed by run().

This post builds the same support triage agent as the Building AI agents with Vercel AI SDK post - lookup customers and invoices, search a knowledge base, then create a ticket or escalate - but uses the OpenAI SDK instead of the Vercel tool loop.

For lower-level API access, see the OpenAI Responses API post. For the Vercel AI SDK alternative (generateText, stopWhen, stepCountIs), see the Vercel AI SDK agents post. For the same scenario with LangChain (createAgent, tool(), agent.invoke), see Building AI agents with LangChain.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • @openai/agents and zod installed (npm i @openai/agents zod)
  • OPENAI_API_KEY set in the environment

Mental model - turns and the agent loop

A turn is one model generation. In that turn the model either:

  • returns final output (the run ends), or
  • returns tool calls (the SDK executes them and starts another turn with the results), or
  • requests a handoff to another agent (control switches, history is preserved, loop continues)

Typical flow for the support triage agent: user question → model calls lookup tools (get_customer, get_invoice, search_knowledge_base) → model creates a ticket or escalates → final answer.

maxTurns: 8 means “stop after 8 turns” (eight model generations), not eight individual tool calls. A single turn can include multiple parallel tool calls.

When you omit maxTurns, the SDK defaults to 10 as a safety cap.

Support triage scenario

Example prompt:

Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?

A realistic chain:

  1. get_customer - plan tier, open ticket count
  2. get_invoice - amount, status, payment IDs
  3. search_knowledge_base - duplicate-charge and refund policy
  4. create_support_ticket or escalate_to_human - write action or escalation

The demo uses in-memory fixtures (customers, invoices, knowledge-base articles) so scripts run without a database.

Defining multiple tools

Register tools with tool() and Zod parameters. Clear description values help the model pick the right tool.

import { tool } from '@openai/agents';
import { z } from 'zod';
const getCustomer = tool({
name: 'get_customer',
description: 'Look up a customer account by ID',
parameters: z.object({
customerId: z.string().describe('Customer ID, e.g. cus_1042'),
}),
execute: async ({ customerId }) => {
const customer = customers.find((item) => item.id === customerId);
if (!customer) {
return { found: false, customerId, error: 'Customer not found' };
}
return { found: true, customer };
},
});
const getInvoice = tool({
name: 'get_invoice',
description: 'Look up an invoice by ID, including payment IDs and status',
parameters: z.object({
invoiceId: z.string().describe('Invoice ID, e.g. inv_8891'),
}),
execute: async ({ invoiceId }) => {
const invoice = invoices.find((item) => item.id === invoiceId);
if (!invoice) {
return { found: false, invoiceId, error: 'Invoice not found' };
}
return { found: true, invoice };
},
});
const searchKnowledgeBase = tool({
name: 'search_knowledge_base',
description: 'Search internal support articles by keyword',
parameters: z.object({
query: z.string().describe('Search terms, e.g. duplicate charge refund'),
}),
execute: async ({ query }) => {
// keyword match against mocked articles
return { query, articles: matches };
},
});

Add write tools for outcomes:

const createSupportTicket = tool({
name: 'create_support_ticket',
description: 'Create a support ticket after gathering customer and policy context',
parameters: z.object({
customerId: z.string(),
subject: z.string().min(3),
priority: z.enum(['low', 'medium', 'high']),
summary: z.string().min(10),
}),
execute: async (input) => {
const ticket = createTicket(input);
return { created: true, ticket };
},
});
const escalateToHuman = tool({
name: 'escalate_to_human',
description: 'Escalate when policy requires manual review',
parameters: z.object({
customerId: z.string(),
reason: z.string().min(10),
urgency: z.enum(['normal', 'high']),
}),
execute: async (input) => ({
escalated: true,
queue: input.urgency === 'high' ? 'billing-urgent' : 'billing-standard',
...input,
}),
});

Return structured objects from execute. The SDK serializes them as tool results for the next turn. Return explicit errors (for example { found: false, error: '...' }) so the model can recover instead of throwing.

Running an agent

Define an Agent with instructions and tools, then call run():

import { Agent, run } from '@openai/agents';
const agent = new Agent({
name: 'Support Triage',
model: 'gpt-5.5',
instructions: `You are a billing support triage agent.
- Look up customer and invoice before recommending refunds.
- Search the knowledge base for policy guidance.
- Create a ticket when you can resolve within policy.
- Call escalate_to_human when manual review is required.`,
tools: [
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
],
});
const result = await run(
agent,
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
{ maxTurns: 8 },
);
console.log(result.finalOutput);

Use a model that supports tool calling.

maxTurns - cap the number of turns

maxTurns(n) stops once the run reaches n turns. Use it on every production agent to prevent runaway loops and unbounded API cost. When the cap is exceeded, the SDK throws MaxTurnsExceededError.

Use caseSuggested cap
Single tool, then answer2
Chat with occasional tool use3–5
Task agents (triage, research)8–15
Long autonomous workflows15–20 (with monitoring)

Tight vs relaxed cap on the same prompt:

import { Agent, run } from '@openai/agents';
// Stops after 3 turns even if the model still wants more context
const tight = await run(agent, prompt, { maxTurns: 3 });
// Allows a fuller investigation chain
const relaxed = await run(agent, prompt, { maxTurns: 8 });

Inspecting runs

The newItems array on the result contains tool calls, tool outputs, and messages from the run. Use it for debugging:

const result = await run(agent, prompt, { maxTurns: 8 });
for (const item of result.newItems) {
if (item.type === 'tool_call_item') {
console.log('tool:', item.rawItem.name, item.rawItem.arguments);
}
if (item.type === 'tool_call_output_item') {
console.log('output:', item.output);
}
}
console.log('lastAgent:', result.lastAgent.name);
console.log('answer:', result.finalOutput);

The SDK emits traces automatically. Set workflowName on a custom Runner to group related runs in the OpenAI Traces dashboard.

Handoffs

For multi-agent workflows, define specialist agents and wire them with Agent.create() and handoffs. The triage agent in this post stays single-agent, but handoffs are the SDK's way to delegate between agents (similar to routing a case to a billing specialist):

import { Agent } from '@openai/agents';
const billingAgent = new Agent({
name: 'Billing Specialist',
instructions: 'Handle refund and duplicate-charge cases.',
tools: [getInvoice, searchKnowledgeBase, createSupportTicket],
});
const triageAgent = Agent.create({
name: 'Triage',
instructions: 'Route billing cases to the billing specialist when needed.',
handoffs: [billingAgent],
});

After a run, check result.lastAgent to see which agent produced the final output.

Streaming

Pass stream: true to receive events as the run progresses:

import { Agent, run } from '@openai/agents';
const stream = await run(agent, prompt, { maxTurns: 8, stream: true });
process.stdout.write('Answer: ');
for await (const event of stream) {
if (event.type === 'raw_model_stream_event' && event.data.type === 'output_text_delta') {
process.stdout.write(event.data.delta);
}
if (event.type === 'run_item_stream_event' && event.name === 'tool_called') {
console.error(`\nTool: ${event.item.rawItem.name}`);
}
}
await stream.completed;
console.log('\nDone:', stream.finalOutput);

Text streams incrementally. Tool calls appear as run_item_stream_event events between text segments.

Production notes

  • Always set maxTurns - do not rely on the default cap without monitoring
  • Cost - each turn is another model call; inspect newItems or stream events for tool usage
  • Tool errors - return structured errors from execute instead of throwing when the model should retry or escalate
  • Instructions - keep policy rules in instructions, not only in the user prompt
  • Tracing - use the OpenAI Traces dashboard to debug multi-turn runs
  • Alternatives - hosted tools (web search, code interpreter), MCP servers, and sandbox agents are covered in the official docs

Demo

Runnable scripts for each section live in the openai-agents-sdk-demo folder. Get access via code demos.

Infrastructure as Code (IaC) with AWS CDK (EC2 Example)

June 11, 2026

Infrastructure as Code (IaC) is a DevOps approach where infrastructure is defined and managed using code instead of manual setup. This makes environments reproducible, version-controlled, and easy to scale.

In this guide, you'll provision an AWS EC2 instance using the AWS Cloud Development Kit (CDK). CDK lets you define infrastructure in TypeScript (or other languages), synthesize CloudFormation templates, and deploy stacks through the CDK CLI.

Requirements

Before starting, install:

  • Node.js version 26
  • AWS CDK CLI (npm i -g aws-cdk)
  • AWS CLI

AWS Credentials Setup

  1. Go to IAM → Security credentials in AWS
  2. Create access keys
  3. Configure locally:
aws configure

This stores credentials in:

  • ~/.aws/credentials
  • ~/.aws/config

CDK requires CloudFormation permissions (and bootstrap-related IAM/S3 access). An IAM user scoped only for Terraform EC2 is often insufficient - attach broader permissions or use a dedicated CDK user.

Project Structure

A simple CDK TypeScript setup:

.
├── bin/
│ └── app.ts
├── lib/
│ └── ec2-stack.ts
├── cdk.json
├── package.json
└── tsconfig.json

Install dependencies:

npm i

App Entry

The CDK app wires stacks together in bin/app.ts:

#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { Ec2Stack } from '../lib/ec2-stack';
const app = new cdk.App();
new Ec2Stack(app, 'Ec2Stack', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION ?? 'eu-north-1',
},
instanceName: app.node.tryGetContext('instanceName') ?? 'MyEC2Name',
});

Set the target account and region once per shell session:

export CDK_DEFAULT_ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
export CDK_DEFAULT_REGION=eu-north-1

On Windows PowerShell:

$env:CDK_DEFAULT_ACCOUNT = aws sts get-caller-identity --query Account --output text
$env:CDK_DEFAULT_REGION = "eu-north-1"

Stack and EC2 Instance

Define the stack in lib/ec2-stack.ts:

import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';
export interface Ec2StackProps extends cdk.StackProps {
instanceName?: string;
}
export class Ec2Stack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: Ec2StackProps) {
super(scope, id, props);
const instanceName = props?.instanceName ?? 'MyEC2Name';
const instance = new ec2.Instance(this, 'AppServer', {
vpc: ec2.Vpc.fromLookup(this, 'DefaultVpc', { isDefault: true }),
instanceType: ec2.InstanceType.of(
ec2.InstanceClass.T3,
ec2.InstanceSize.MICRO,
),
machineImage: ec2.MachineImage.latestAmazonLinux2023(),
instanceName,
});
new cdk.CfnOutput(this, 'InstanceId', {
value: instance.instanceId,
description: 'EC2 instance ID',
});
new cdk.CfnOutput(this, 'InstancePublicIp', {
value: instance.instancePublicIp,
description: 'Public IP address',
});
}
}

CDK resolves the default VPC during synthesis. Amazon Linux 2023 is selected automatically for the current region.

Configuration

CDK settings live in cdk.json:

{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"context": {
"instanceName": "MyEC2Name"
}
}

Change instanceName in context to override the default tag without editing stack code.

CDK Workflow

Bootstrap the environment (once per account/region):

  • cdk bootstrap

Preview the CloudFormation template:

  • cdk synth

Compare deployed stack with local changes:

  • cdk diff

Deploy the stack:

  • cdk deploy

Destroy infrastructure:

  • cdk destroy

Important Notes

  • State management

CDK deploys through CloudFormation. Stack state lives in AWS, not in a local file like Terraform's terraform.tfstate.

  • Bootstrap stack

The first cdk bootstrap creates an S3 bucket and IAM roles CDK uses for deployments. Keep the bootstrap stack in place while you use CDK in that account/region.

  • Idempotency

Running cdk deploy again updates the stack in place when possible instead of recreating resources unnecessarily.

  • Version control

Treat CDK code like application code. Commit cdk.json and lockfiles; do not commit cdk.out/ or node_modules/.

Demo

CDK project files for this post live in the cdk-aws-ec2 folder. Get access via code demos.

AI image generation with OpenAI API

June 10, 2026

OpenAI exposes image generation through the Image API (POST /images/generations). The official openai npm package wraps it as client.images.generate. This post walks through the main request parameters and how to save generated images from Node.js.

The examples use gpt-image-2, OpenAI's latest GPT Image model. GPT Image models always return base64-encoded image data in data[].b64_json. Use output_format for the on-disk file type and put artistic direction in the prompt.

For text generation with the same package, see the Chat Completions API and Responses API posts. Image generation is also available through Responses API tools, but this post focuses on the dedicated Image API endpoint.

The running scenario: generate marketing hero images for a fictional todo app.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • openai package installed (npm i openai)

Client setup

Create a client with your API key (read from the environment in production).

import OpenAI from 'openai';
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

The same SDK can target other hosts that implement a compatible API by setting baseURL and apiKey:

const client = new OpenAI({
apiKey: process.env.LLM_API_KEY,
baseURL: 'https://your-gateway.example/v1',
});

Azure OpenAI uses AzureOpenAI instead. Confirm your provider supports the Image API and the model you pass to model.

Basic integration

Call client.images.generate with model and prompt. The examples use gpt-image-2, older snapshots include gpt-image-1.5, gpt-image-1, and gpt-image-1-mini. Pin a snapshot (for example gpt-image-2-2026-04-21) when you need stable behavior across deploys.

The prompt describes what to generate. GPT Image models accept up to about 32,000 characters. Be specific about subject, layout, colors, and style.

GPT Image models always return base64 in data[].b64_json. Decode it and write the file yourself.

import { writeFile } from 'node:fs/promises';
const prompt = `
Minimal flat illustration for a productivity app landing page.
Show a todo dashboard with a checklist, calendar widget, and soft pastel palette.
No text labels on screen elements.
`.trim();
const result = await client.images.generate({
model: 'gpt-image-2',
prompt,
});
await writeFile('hero.png', Buffer.from(result.data[0].b64_json, 'base64'));

n

Use n to generate multiple images in one request (default 1, maximum 10). Loop over result.data to save each image.

import { writeFile } from 'node:fs/promises';
const result = await client.images.generate({
model: 'gpt-image-2',
prompt:
'Minimal flat illustration of a todo app dashboard, variant layout, soft pastel colors',
n: 2,
});
for (const [index, item] of result.data.entries()) {
await writeFile(
`hero-${index}.png`,
Buffer.from(item.b64_json, 'base64'),
);
}

Size

Control dimensions with size. Common presets are 1024x1024 (square), 1536x1024 (landscape), and 1024x1536 (portrait). auto lets the model pick based on the prompt.

gpt-image-2 also accepts custom WIDTHxHEIGHT strings when width and height are multiples of 16, the aspect ratio is between 1:3 and 3:1, and total pixels stay within the documented limits.

const result = await client.images.generate({
model: 'gpt-image-2',
prompt:
'Minimal flat illustration of a todo app dashboard, portrait orientation, soft pastel colors',
size: '1024x1536',
});

Quality

Set rendering quality with quality. Use low for fast drafts and iterations, then medium or high for final assets. Default is auto.

const draft = await client.images.generate({
model: 'gpt-image-2',
prompt:
'Minimal flat illustration of a todo app dashboard, soft pastel colors',
quality: 'low',
});
const final = await client.images.generate({
model: 'gpt-image-2',
prompt:
'Minimal flat illustration of a todo app dashboard, soft pastel colors, polished details',
quality: 'high',
size: '1024x1536',
});

Output format

GPT Image models return base64 in the JSON response. Use output_format to control the encoded file type: png (default), jpeg, or webp.

import { writeFile } from 'node:fs/promises';
const result = await client.images.generate({
model: 'gpt-image-2',
prompt:
'Minimal flat illustration of a todo app dashboard, soft pastel colors',
output_format: 'jpeg',
});
await writeFile('hero.jpg', Buffer.from(result.data[0].b64_json, 'base64'));

Compression

When output_format is jpeg or webp, set output_compression from 0 to 100 to trade file size for quality. JPEG is often faster than PNG when latency matters.

const result = await client.images.generate({
model: 'gpt-image-2',
prompt:
'Minimal flat illustration of a todo app dashboard, soft pastel colors',
output_format: 'webp',
output_compression: 50,
});

Background

Use background: 'transparent' with png or webp on models that support it when you need a cutout asset. gpt-image-2 does not support transparent backgrounds; use gpt-image-1.5 or an earlier GPT Image model for that workflow, or bake the background into the prompt.

const result = await client.images.generate({
model: 'gpt-image-1.5',
prompt: 'Flat icon of a checkmark, no background, centered',
output_format: 'png',
background: 'transparent',
});

Production notes

  • Cost scales with quality and size. See OpenAI pricing before generating at scale.
  • Moderation - use moderation: 'auto' (default) or 'low' on GPT Image models when you need less restrictive filtering.
  • Errors - handle image_generation_user_error (for example moderation_blocked) by changing the prompt or inputs; do not blindly retry.
  • Latency - complex prompts can take up to about two minutes.
  • Storage - decode and persist files yourself. GPT Image responses are base64 in JSON.

Demo

Runnable scripts for each section live in the openai-image-generation-demo folder. Get access via code demos.

Building AI agents with Vercel AI SDK

June 9, 2026

The Vercel AI SDK treats agents as tool-calling loops: the model generates text or invokes tools, the SDK runs those tools, and the loop continues until the model answers or a stop condition fires.

This post builds a support triage agent that looks up customers and invoices, searches an internal knowledge base, and either opens a ticket or escalates to a human. It builds on the LLM integration with Vercel AI SDK post and focuses on multiple tools, stopWhen, and stepCountIs.

For external tools exposed over MCP instead of SDK-native tool() handlers, see the MCP server with Node.js post. For the same triage scenario with the official OpenAI Agents SDK (@openai/agents, run(), maxTurns), see the dedicated post. For the LangChain stack (createAgent, tool(), LangGraph loop), see Building AI agents with LangChain.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • ai, @ai-sdk/openai, and zod installed (npm i ai @ai-sdk/openai zod)
  • Client setup from the Vercel AI SDK integration post

Mental model - steps and the tool loop

A step is one model generation. In that step the model either:

  • returns text (the loop ends), or
  • returns tool calls (the SDK executes them and starts another step with the results)

Typical flow for the support triage agent: user question → model calls lookup tools (getCustomer, getInvoice, searchKnowledgeBase) → model creates a ticket or escalates → final answer. stopWhen can end the loop before or after the write tools run.

stepCountIs(5) means "stop after 5 steps" (five model generations), not five individual tool calls. A single step can include multiple parallel tool calls.

When you pass tools without stopWhen, the SDK defaults to stepCountIs(20) as a safety cap.

Support triage scenario

Example prompt:

Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?

A realistic chain:

  1. getCustomer - plan tier, open ticket count
  2. getInvoice - amount, status, payment IDs
  3. searchKnowledgeBase - duplicate-charge and refund policy
  4. createSupportTicket or escalateToHuman - write action or sentinel stop

The demo uses in-memory fixtures (customers, invoices, knowledge-base articles) so scripts run without a database.

Defining multiple tools

Register tools with tool() and Zod inputSchema. Clear description values help the model pick the right tool.

import { tool } from 'ai';
import { z } from 'zod';
const getCustomer = tool({
description: 'Look up a customer account by ID',
inputSchema: z.object({
customerId: z.string().describe('Customer ID, e.g. cus_1042'),
}),
execute: async ({ customerId }) => {
const customer = customers.find((item) => item.id === customerId);
if (!customer) {
return { found: false, customerId, error: 'Customer not found' };
}
return { found: true, customer };
},
});
const getInvoice = tool({
description: 'Look up an invoice by ID, including payment IDs and status',
inputSchema: z.object({
invoiceId: z.string().describe('Invoice ID, e.g. inv_8891'),
}),
execute: async ({ invoiceId }) => {
const invoice = invoices.find((item) => item.id === invoiceId);
if (!invoice) {
return { found: false, invoiceId, error: 'Invoice not found' };
}
return { found: true, invoice };
},
});
const searchKnowledgeBase = tool({
description: 'Search internal support articles by keyword',
inputSchema: z.object({
query: z.string().describe('Search terms, e.g. duplicate charge refund'),
}),
execute: async ({ query }) => {
// keyword match against mocked articles
return { query, articles: matches };
},
});

Add write tools for outcomes:

const createSupportTicket = tool({
description: 'Create a support ticket after gathering customer and policy context',
inputSchema: z.object({
customerId: z.string(),
subject: z.string().min(3),
priority: z.enum(['low', 'medium', 'high']),
summary: z.string().min(10),
}),
execute: async (input) => {
const ticket = createTicket(input);
return { created: true, ticket };
},
});
const escalateToHuman = tool({
description: 'Escalate when policy requires manual review',
inputSchema: z.object({
customerId: z.string(),
reason: z.string().min(10),
urgency: z.enum(['normal', 'high']),
}),
execute: async (input) => ({
escalated: true,
queue: input.urgency === 'high' ? 'billing-urgent' : 'billing-standard',
...input,
}),
});

Return structured objects from execute. The SDK serializes them as tool results for the next step. Return explicit errors (for example { found: false, error: '...' }) so the model can recover instead of throwing.

Multi-step triage with generateText

Pass all tools and a system prompt with triage rules:

import { generateText, stepCountIs } from 'ai';
const { text, steps } = await generateText({
model: openai('gpt-5.5'),
system: `You are a billing support triage agent.
- Look up customer and invoice before recommending refunds.
- Search the knowledge base for policy guidance.
- Create a ticket when you can resolve within policy.
- Call escalateToHuman when manual review is required.`,
tools: {
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
},
stopWhen: stepCountIs(8),
prompt:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
});
console.log('steps:', steps.length);
console.log(text);

Use a model that supports tool calling (same requirement as web search in the Vercel AI SDK post).

stopWhen - when the loop stops

stopWhen defines stopping conditions for the tool loop. Conditions are evaluated only when the last step contains tool results.

  • A single condition stops when that condition returns true
  • An array stops when any condition returns true (OR logic)
  • Without stopWhen, the SDK applies stepCountIs(20)

The loop also ends naturally when the model returns text without further tool calls.

stepCountIs - cap the number of steps

stepCountIs(n) stops once steps.length reaches n. Use it on every production agent to prevent runaway loops and unbounded API cost.

Use caseSuggested cap
Single tool, then answer2 (tool step + text step)
Chat with occasional tool use3-5
Task agents (triage, research)8-15
Long autonomous workflows15-20 (with monitoring)

Tight vs relaxed cap on the same prompt:

import { generateText, stepCountIs } from 'ai';
// Stops after 3 steps even if the model still wants more context
const capped = await generateText({
model: openai('gpt-5.5'),
tools: supportTools,
stopWhen: stepCountIs(3),
prompt: '...',
});
// Allows a fuller investigation chain
const relaxed = await generateText({
model: openai('gpt-5.5'),
tools: supportTools,
stopWhen: stepCountIs(8),
prompt: '...',
});

Combining hasToolCall with stepCountIs

hasToolCall('toolName') stops when the model invokes a specific tool in the latest step. Pair it with stepCountIs for a hard cap plus a sentinel tool:

import { generateText, stepCountIs, hasToolCall } from 'ai';
const { text, steps } = await generateText({
model: openai('gpt-5.5'),
system: TRIAGE_INSTRUCTIONS,
tools: supportTools,
stopWhen: [stepCountIs(10), hasToolCall('escalateToHuman')],
prompt:
'Customer cus_2201 on the starter plan reports a duplicate $190 charge on invoice inv_9104.',
});

escalateToHuman works well as a sentinel: the loop stops as soon as the model decides the case needs a human, without waiting for a final text-only step.

Inspecting steps and usage

The steps array on the result contains per-step tool calls, tool results, finish reason, and usage. Use it for debugging and cost tracking:

const { text, steps, totalUsage } = await generateText({
model: openai('gpt-5.5'),
tools: supportTools,
stopWhen: stepCountIs(8),
prompt: '...',
});
for (const [index, step] of steps.entries()) {
console.log(`step ${index + 1}`);
console.log(' toolCalls:', step.toolCalls?.map((c) => c.toolName));
console.log(' usage:', step.usage);
}
console.log('totalUsage:', totalUsage);

With streamText, pass onStepFinish to log each step as it completes.

ToolLoopAgent - reusable agent definition

ToolLoopAgent wraps the same loop for reuse across scripts and API routes. It accepts the same settings as generateText (tools, stopWhen, instructions).

import { ToolLoopAgent, stepCountIs } from 'ai';
const supportTriageAgent = new ToolLoopAgent({
model: openai('gpt-5.5'),
instructions: TRIAGE_INSTRUCTIONS,
tools: supportTools,
stopWhen: stepCountIs(8),
});
const result = await supportTriageAgent.generate({
prompt:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
onStepFinish: async ({ stepNumber, usage, toolCalls }) => {
console.log(`step ${stepNumber + 1}`, {
tokens: usage.totalTokens,
tools: toolCalls?.map((call) => call.toolName),
});
},
});
console.log(result.text);

Use .stream() for streaming. For Next.js chat UIs, see createAgentUIStreamResponse in the AI SDK agents docs.

Streaming with tools

streamText supports the same tools and stopWhen settings:

import { streamText, stepCountIs } from 'ai';
const result = streamText({
model: openai('gpt-5.5'),
system: TRIAGE_INSTRUCTIONS,
tools: supportTools,
stopWhen: stepCountIs(8),
prompt: 'Customer cus_1042 says they were charged twice for invoice inv_8891.',
onStepFinish: async ({ stepNumber, toolCalls }) => {
console.error(`step ${stepNumber + 1}:`, toolCalls?.map((c) => c.toolName));
},
});
for await (const part of result.textStream) {
process.stdout.write(part);
}

Text streams incrementally. Tool calls run between text segments as the loop progresses.

Production notes

  • Always set stopWhen - do not rely on the default stepCountIs(20) in production without monitoring
  • Cost - each step is another model call; log steps or onStepFinish usage
  • Tool errors - return structured errors from execute instead of throwing when the model should retry or escalate
  • Instructions - keep policy rules in system / instructions, not only in the user prompt
  • Same patterns elsewhere - PR review (listPRsgetCheckssubmitReview) or job-fit scoring use the same loop mechanics with different tools

Demo

Runnable scripts for each section live in the vercel-ai-sdk-agents-demo folder. Get access via code demos.

LLM integration with OpenRouter

June 8, 2026

OpenRouter is a unified API gateway to hundreds of language models from providers such as OpenAI, Anthropic, Google, and Meta. You use one API key and one billing surface, and swap models by changing a provider/model slug. OpenRouter exposes a Chat Completions-compatible HTTP API.

This post shows three Node.js integration paths: the official @openrouter/sdk, the openai package with baseURL, and the Vercel AI SDK with @openrouter/ai-sdk-provider.

For deeper patterns on each stack, see the Chat Completions API, OpenAI Responses API (OpenAI direct only), and Vercel AI SDK posts.

Prerequisites

  • OpenRouter account
  • API key
  • Credits or billing enabled as needed
  • Node.js version 26
  • Install packages for the path you use:
    • @openrouter/sdk (npm i @openrouter/sdk)
    • openai (npm i openai)
    • ai and @openrouter/ai-sdk-provider (npm i ai @openrouter/ai-sdk-provider)

Configuration

Read credentials from the environment in production.

VariablePurpose
OPENROUTER_API_KEYBearer token from OpenRouter settings
OPENROUTER_MODELDefault model slug, for example openai/gpt-5.5
OPENROUTER_SITE_URLOptional site URL sent as HTTP-Referer for rankings on openrouter.ai
OPENROUTER_SITE_TITLEOptional app name sent as X-OpenRouter-Title

Model IDs use the provider/model format, for example openai/gpt-5.5, anthropic/claude-opus-4.8, or google/gemini-3.1-flash-lite. Browse the full catalog at openrouter.ai/models.

The examples below use openai/gpt-5.5, matching the model in the other LLM posts in this series. Override it with OPENROUTER_MODEL when you want a different model.

@openrouter/sdk

OpenRouter's official TypeScript SDK is type-safe and generated from the OpenAPI spec.

Client setup

import { OpenRouter } from '@openrouter/sdk';
const client = new OpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
httpReferer: process.env.OPENROUTER_SITE_URL,
appTitle: process.env.OPENROUTER_SITE_TITLE,
});

Basic integration

const response = await client.chat.send({
chatRequest: {
model: process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5',
messages: [
{ role: 'user', content: 'Write a one-sentence bedtime story about a unicorn.' },
],
},
});
console.log(response.choices[0].message.content);

System prompt

Add a system message before the user turn to set tone, format, and role.

const response = await client.chat.send({
chatRequest: {
model: process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5',
messages: [
{ role: 'system', content: 'Reply in one short sentence. Use plain language.' },
{ role: 'user', content: 'Explain what an LLM is.' },
],
},
});
console.log(response.choices[0].message.content);

Streaming

Set stream: true and read incremental text from choices[0].delta.content.

const stream = await client.chat.send({
chatRequest: {
model: process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5',
messages: [{ role: 'user', content: 'List three colors.' }],
stream: true,
},
});
process.stdout.write('[stream] ');
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) {
process.stdout.write(delta);
}
}
process.stdout.write('\n');

Model switching

Change only the model string to route the same code to a different provider.

const models = ['openai/gpt-5.5', 'google/gemini-3.1-flash-lite'];
for (const model of models) {
const response = await client.chat.send({
chatRequest: {
model,
messages: [{ role: 'user', content: 'Reply with exactly one word: ok.' }],
},
});
console.log(model, '->', response.choices[0].message.content);
}

openai package

If you already use the OpenAI SDK, point it at OpenRouter with baseURL. The request shape matches the Chat Completions API.

Client setup

import OpenAI from 'openai';
const client = new OpenAI({
apiKey: process.env.OPENROUTER_API_KEY,
baseURL: 'https://openrouter.ai/api/v1',
defaultHeaders: {
'HTTP-Referer': process.env.OPENROUTER_SITE_URL,
'X-OpenRouter-Title': process.env.OPENROUTER_SITE_TITLE,
},
});

Basic integration

const completion = await client.chat.completions.create({
model: process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5',
messages: [
{ role: 'user', content: 'Write a one-sentence bedtime story about a unicorn.' },
],
});
console.log(completion.choices[0].message.content);

System prompt

const completion = await client.chat.completions.create({
model: process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5',
messages: [
{ role: 'system', content: 'Reply in one short sentence. Use plain language.' },
{ role: 'user', content: 'Explain what an LLM is.' },
],
});
console.log(completion.choices[0].message.content);

Streaming

const stream = await client.chat.completions.create({
model: process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5',
messages: [{ role: 'user', content: 'List three colors.' }],
stream: true,
});
process.stdout.write('[stream] ');
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) {
process.stdout.write(delta);
}
}
process.stdout.write('\n');

For JSON schema output, Markdown-to-HTML, and few-shot prompting, reuse the patterns from the Chat Completions post with the OpenRouter client and model slug above.

Vercel AI SDK

The @openrouter/ai-sdk-provider package exposes OpenRouter models to generateText, streamText, and related helpers from the ai package. See the OpenRouter Vercel AI SDK guide for the full integration reference.

Client setup

import { createOpenRouter } from '@openrouter/ai-sdk-provider';
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
appUrl: process.env.OPENROUTER_SITE_URL,
appName: process.env.OPENROUTER_SITE_TITLE,
});

The returned provider is callable. Pass a model slug directly: openrouter('openai/gpt-5.5').

Basic integration

import { generateText } from 'ai';
const { text } = await generateText({
model: openrouter(process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5'),
prompt: 'Write a one-sentence bedtime story about a unicorn.',
});
console.log(text);

System prompt

const { text } = await generateText({
model: openrouter(process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5'),
system: 'Reply in one short sentence. Use plain language.',
prompt: 'Explain what an LLM is.',
});
console.log(text);

Streaming

import { streamText } from 'ai';
const result = streamText({
model: openrouter(process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5'),
prompt: 'List three colors.',
});
process.stdout.write('[stream] ');
for await (const part of result.textStream) {
process.stdout.write(part);
}
process.stdout.write('\n');

For structured output, embeddings, and web search, see the Vercel AI SDK post. Those patterns apply when you call OpenAI directly; OpenRouter coverage depends on the model and endpoint.

Demo

Runnable scripts for each integration path live in the openrouter-demo folder. Get access via code demos.

LLM integration with Vercel AI SDK

June 7, 2026

Large language models (LLMs) understand and generate text from prompts. The Vercel AI SDK is a provider-agnostic layer over LLM APIs - core functions are generateText, streamText, and embed. This post uses the OpenAI provider and mirrors the patterns from the OpenAI Responses API post.

For the lower-level openai npm package, see the Chat Completions API and Responses API posts. For multi-tool agents with stopWhen and stepCountIs, see the Building AI agents with Vercel AI SDK post. For the same triage scenario with the OpenAI Agents SDK, see the dedicated post.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • ai, @ai-sdk/openai, and zod installed (npm i ai @ai-sdk/openai zod)
  • For Markdown output: marked, dompurify, and jsdom (npm i marked dompurify jsdom)

Client setup

Create an OpenAI provider with your API key (read from the environment in production).

import { createOpenAI } from '@ai-sdk/openai';
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });

For OpenRouter, use the dedicated @openrouter/ai-sdk-provider package - see the OpenRouter integration post. The same provider can target other hosts that implement a compatible API by setting baseURL and apiKey:

const openai = createOpenAI({
apiKey: process.env.LLM_API_KEY,
baseURL: 'https://your-gateway.example/v1',
});

Many third-party gateways support Chat Completions only. The examples below use openai(model) (Responses API path). If your provider does not support it, switch to openai.chat(model) and skip the web search example.

Basic integration

Pass a string as prompt and read text from the result.

import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
const { text } = await generateText({
model: openai('gpt-5.5'),
prompt: 'Write a one-sentence bedtime story about a unicorn.',
});
console.log(text);

System prompt

Use the system parameter for stable behavior (tone, format, role). It takes precedence over casual wording in the user message.

const { text } = await generateText({
model: openai('gpt-5.5'),
system: 'Reply in one short sentence. Use plain language.',
prompt: 'Explain what an LLM is.',
});
console.log(text);

Few-shot prompting

Pass prior turns in a messages array with user and assistant roles, then the new user message. Keep task rules in system.

const { text } = await generateText({
model: openai('gpt-5.5'),
system:
'Classify sentiment as exactly one word: positive, negative, or neutral.',
messages: [
{ role: 'user', content: 'I love this!' },
{ role: 'assistant', content: 'positive' },
{ role: 'user', content: 'This is awful.' },
{ role: 'assistant', content: 'negative' },
{ role: 'user', content: 'It is fine I guess.' },
],
});
console.log(text);

Streaming

Use streamText and iterate over textStream for incremental text.

import { streamText } from 'ai';
const result = streamText({
model: openai('gpt-5.5'),
prompt: 'List three colors.',
});
for await (const part of result.textStream) {
process.stdout.write(part);
}

Structured output with JSON schema

Constrain the model to JSON matching your schema via Output.object() and a Zod schema. The SDK validates the result.

import { generateText, Output } from 'ai';
import { z } from 'zod';
const { output } = await generateText({
model: openai('gpt-5.5'),
prompt: 'The film Inception was directed by Christopher Nolan.',
output: Output.object({
schema: z.object({
title: z.string(),
director: z.string(),
}),
schemaName: 'movie_summary',
}),
});
console.log(output.title, output.director);

Markdown output to HTML

Ask for Markdown in system, then convert text to HTML and sanitize before rendering (for example with innerHTML in the browser or when storing HTML).

import { marked } from 'marked';
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';
const purify = DOMPurify(new JSDOM('').window);
const { text } = await generateText({
model: openai('gpt-5.5'),
system: 'Reply in Markdown only. Use a heading and a short bullet list.',
prompt: 'Explain what an LLM is in three bullet points.',
});
const markdown = text;
const html = marked.parse(markdown);
const safeHtml = purify.sanitize(html);

Always run DOMPurify.sanitize on model-generated HTML. The model can emit unsafe markup. Sanitization strips scripts and other dangerous content.

Web search tool

Enable the built-in web search tool when the answer should use current information from the web.

const result = await generateText({
model: openai('gpt-5.5'),
tools: { web_search: openai.tools.webSearch() },
prompt: 'What was a major tech headline this week? Cite sources briefly.',
});
console.log(result.text);

Web search adds latency and tool usage cost. Use a model that supports tools.

Embeddings

Embeddings are numeric vectors that represent the semantic meaning of text. Use them for semantic search, clustering, and RAG.

Pass a single string to embed and read the vector from embedding.

import { embed } from 'ai';
const { embedding } = await embed({
model: openai.embedding('text-embedding-3-small'),
value: 'How do I connect pgvector to PostgreSQL?',
});
console.log(embedding.length);

Pass multiple strings in a values array with embedMany. Results are in the same order as the input.

import { embedMany } from 'ai';
const chunks = [
'pgvector adds vector similarity search to PostgreSQL.',
'LangChain helps split long documents into retrieval-friendly chunks.',
'RAG retrieves context first, then asks an LLM to answer.',
];
const { embeddings } = await embedMany({
model: openai.embedding('text-embedding-3-small'),
values: chunks,
});
console.log(embeddings.length); // 3

For a full RAG flow with pgvector, see the RAG with OpenAI embeddings post. For LangChain basics (LCEL, Runnables, when to use LangChain), see the LangChain overview post.

Demo

Runnable scripts for each section live in the vercel-ai-sdk-demo folder. Get access via code demos.

Building an MCP server with Node.js

June 6, 2026

The Model Context Protocol (MCP) is an open standard for connecting AI hosts (Claude, ChatGPT, Cursor, VS Code, and others) to external context and actions through a structured protocol instead of ad-hoc plugins.

The host runs an MCP client. Your application is the MCP server, exposing tools (actions the model can invoke), resources (read-only data), and optionally prompts (reusable message templates). Communication uses JSON-RPC over a transport such as stdio or HTTP. After the client connects, the server completes an initialization handshake (initializeinitialized). The host discovers capabilities via tools/list, resources/list, and prompts/list, then calls tools or reads resources at runtime.

This post shows how to build a small todo MCP server with Node.js using the official @modelcontextprotocol/sdk package and Zod schemas.

Prerequisites

  • Node.js version 26
  • npm i @modelcontextprotocol/sdk zod
  • Optional for client testing: Claude Desktop and/or ChatGPT (Connectors / Apps)

The stable v1 SDK is @modelcontextprotocol/sdk. A v2 split (@modelcontextprotocol/server, @modelcontextprotocol/client) is in pre-release - this post uses v1, which matches current production tooling.

MCP capabilities - tools, resources, prompts

CapabilityPurposeDemo example
ToolsModel-invoked actions with typed inputsadd_todo, list_todos, mark_todo_done
ResourcesRead-only context the host can fetchtodo://all JSON snapshot
Prompts (optional)Named templates with argumentssummarize-open-todos

Tools are the main integration surface - the model calls them via tools/call. Resources are fetched with resources/read and should stay read-only. Prompts return pre-built messages via prompts/get.

Tool inputs need a schema so clients know parameters. With the TypeScript SDK, pass Zod fields in inputSchema:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const server = new McpServer({ name: 'todo-mcp-server', version: '1.0.0' });
server.registerTool(
'add_todo',
{
description: 'Add a new todo item',
inputSchema: { title: z.string().min(1) },
},
async ({ title }) => ({
content: [{ type: 'text', text: JSON.stringify({ title, done: false }) }],
})
);

Register a resource at a fixed URI:

server.registerResource(
'all-todos',
'todo://all',
{
title: 'All todos',
mimeType: 'application/json',
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify([{ id: 1, title: 'Learn MCP', done: false }]),
},
],
})
);

Register an optional prompt:

server.registerPrompt(
'summarize-open-todos',
{
title: 'Summarize open todos',
description: 'Ask the model to summarize open todos',
},
() => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'Summarize my open todos and suggest a priority order.',
},
},
],
})
);

Building the server

Use a factory so the same server definition works with multiple transports:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const todos = [
{ id: 1, title: 'Learn MCP basics', done: false },
{ id: 2, title: 'Ship demo server', done: false },
];
let nextId = 3;
export function createMcpServer() {
const server = new McpServer({ name: 'todo-mcp-server', version: '1.0.0' });
server.registerTool(
'add_todo',
{
description: 'Add a new todo item',
inputSchema: { title: z.string().min(1) },
},
async ({ title }) => {
const todo = { id: nextId++, title, done: false };
todos.push(todo);
return { content: [{ type: 'text', text: JSON.stringify(todo, null, 2) }] };
}
);
server.registerTool('list_todos', { description: 'List all todo items' }, async () => ({
content: [{ type: 'text', text: JSON.stringify(todos, null, 2) }],
}));
server.registerTool(
'mark_todo_done',
{
description: 'Mark a todo item as done by id',
inputSchema: { id: z.number().int().positive() },
},
async ({ id }) => {
const todo = todos.find((item) => item.id === id);
if (!todo) {
return { content: [{ type: 'text', text: `Todo ${id} not found` }], isError: true };
}
todo.done = true;
return { content: [{ type: 'text', text: JSON.stringify(todo, null, 2) }] };
}
);
// register resource and prompt here (see snippets above)
return server;
}

Tool handlers return { content: [...] }. Set isError: true when a tool fails so the host can surface the error. Resource handlers return { contents: [...] }. Prompt handlers return { messages: [...] }.

The demo uses an in-memory store so you can run it without API keys or a database.

Transports - stdio vs SSE / Streamable HTTP

MCP separates protocol (JSON-RPC messages) from transport (how bytes move between client and server).

Stdio (StdioServerTransport)

The client spawns your server as a child process. JSON-RPC goes over stdin/stdout.

  • Use when: Claude Desktop, Cursor, VS Code, Claude Code, local CLI agents
  • Pros: simplest setup, no ports or firewall rules, no OAuth
  • Cons: one client per process; cloud hosts cannot spawn your local binary
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createMcpServer } from './create-server.js';
const server = createMcpServer();
await server.connect(new StdioServerTransport());

Write logs to stderr only - stdout is the protocol channel.

Remote HTTP - SSE (legacy) vs Streamable HTTP (current)

Early MCP remote servers used HTTP + SSE: POST for client→server requests, Server-Sent Events for server→client streaming. That transport is deprecated.

New servers should use Streamable HTTP (StreamableHTTPServerTransport). It supports POST request/response, optional SSE for notifications, and session management. The v2 SDK removes server-side SSE entirely; client-side SSE remains for legacy servers.

ScenarioTransport
Claude Desktop, local devstdio
Cursor / VS Code project MCPstdio
ChatGPT Apps / ConnectorsStreamable HTTP over public HTTPS
Legacy SSE-only clientsSSE client transport still exists; prefer Streamable HTTP for new servers

Streamable HTTP entry (stateless):

import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createMcpServer } from './create-server.js';
const app = createMcpExpressApp();
const PORT = Number(process.env.PORT) || 3000;
app.post('/mcp', async (req, res) => {
const server = createMcpServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
res.on('close', () => {
transport.close();
server.close();
});
});
app.listen(PORT, () => {
console.error(`MCP server listening on http://127.0.0.1:${PORT}/mcp`);
});

createMcpExpressApp() enables DNS rebinding protection when binding to localhost - recommended for local HTTP servers.

Deploy Streamable HTTP behind HTTPS before exposing it to cloud clients. ChatGPT Connectors also require OAuth 2.1 for production use.

Connecting MCP clients

Claude Desktop (stdio - primary demo path)

Config file on Windows: %APPDATA%\Claude\claude_desktop_config.json. Open it via Settings → Developer → Edit Config.

{
"mcpServers": {
"todo-mcp": {
"command": "node",
"args": ["C:/path/to/demos/ai/mcp-server-nodejs-demo/src/stdio.js"]
}
}
}

Restart Claude Desktop after saving. Claude shows a tool approval UI before executing write operations.

Claude Desktop (remote HTTP)

claude_desktop_config.json validates stdio servers only - do not put a bare url field there expecting it to work. For a public HTTPS MCP server, use Settings → Connectors → Add custom connector. For local HTTP during development, bridge with mcp-remote as a stdio-launched proxy.

ChatGPT (Connectors / Apps)

ChatGPT has no local MCP config file. Register servers in Settings → Apps (or Connectors) with a name, description, and MCP server URL.

Requirements:

  • Public HTTPS endpoint (Streamable HTTP)
  • OAuth 2.1 for production connectors
  • Stdio-only servers need an HTTP wrapper or tunnel before ChatGPT can reach them

ChatGPT shows confirmation modals before write/modify tool calls.

Cursor

Cursor uses .cursor/mcp.json with the same stdio shape as Claude Desktop (command + args).

What else matters

  • Security - validate tool inputs, scope tools narrowly, and never expose secrets in resources.
  • Logging - stderr only for stdio servers; avoid console.log on stdout.
  • Testing - use @modelcontextprotocol/sdk client helpers with StdioClientTransport for smoke tests.
  • Spec evolution - prefer Streamable HTTP over legacy SSE; watch the v2 SDK split when upgrading.

Demo

Runnable scripts for this post live in the mcp-server-nodejs-demo folder in the private demos repository. Get access via code demos.

Supabase basics with Node.js

June 3, 2026

Supabase is an open-source backend platform built around managed PostgreSQL. You get a database, auto-generated REST APIs (via PostgREST), Auth, file Storage, Realtime subscriptions, and Edge Functions - with a dashboard and SQL editor on top.

Compared to running Postgres yourself, Supabase adds hosted infra, API layers, and product features without you wiring them up. Compared to an ORM-only stack, you often talk to Postgres through the Supabase client or SQL, with RLS (row level security) enforcing access at the database layer.

Prerequisites

  • Supabase account (free tier is enough for learning)
  • Node.js version 26
  • @supabase/supabase-js installed (npm i @supabase/supabase-js)

Create a project and database

  1. In the Supabase dashboard, choose New project, pick a region, and set the database password.
  2. Wait until the project is ready.
  3. Open SQL Editor and run the schema below.

The Table Editor is fine for quick experiments; SQL Editor keeps schema reproducible in git and reviews.

create table if not exists public.todos (
id bigint generated always as identity primary key,
title text not null,
done boolean not null default false,
created_at timestamptz not null default now()
);
create or replace function public.list_open_todos()
returns setof public.todos
language sql
security definer
set search_path = public
as $$
select * from public.todos where done = false order by id;
$$;
create or replace function public.mark_todo_done(todo_id bigint)
returns setof public.todos
language sql
security definer
set search_path = public
as $$
update public.todos set done = true where id = todo_id returning *;
$$;
grant execute on function public.list_open_todos() to service_role;
grant execute on function public.mark_todo_done(bigint) to service_role;

This schema skips RLS setup because this post uses the secret key from Node.js, which bypasses RLS. For browser or mobile clients, use the publishable key instead - it obeys Row Level Security when you enable it on tables.

list_open_todos is a Postgres function exposed as an RPC endpoint.

Postgres functions default to security invoker: they run with the caller's permissions, so RLS applies as that role. security definer runs the function as its owner (often a privileged database role), not as the caller.

API keys and connection

On the project Overview tab you will find:

  • Project URL (https://<ref>.supabase.co) - for SUPABASE_URL
  • Publishable key (sb_publishable_..., legacy anon) - for browsers and mobile apps where RLS should limit access; this post does not use it

For Node.js backends, use a secret key from Project SettingsAPI Keys:

  • Secret key(s) (sb_secret_...) - full data access, bypasses RLS; trusted servers only, never in client bundles or public repos. Replaces the legacy service_role key.
  • Reveal or create a secret key in the dashboard (legacy projects: Legacy anon, service_role API keys tab → service_role).

The Supabase JS client talks to PostgREST and can query tables (from) and call registered functions (rpc) only after they exist. It does not run DDL (create table, create function, grant, and similar), so apply the schema in the SQL Editor (or via Supabase CLI migrations in larger projects).

Store values in environment variables:

SUPABASE_URL=https://<ref>.supabase.co
SUPABASE_SECRET_KEY=your-secret-key

Client setup

Create a client with the project URL and secret key. Read values from the environment in real apps; never commit the secret key.

import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY
);

Every table you create in the public schema is available as supabase.from('<table>'). Custom SQL functions are called with supabase.rpc('<function_name>').

Insert data

Insert one or more rows and return the created records with .select().

const { data, error } = await supabase
.from('todos')
.insert([
{ title: 'Learn Supabase client', done: false },
{ title: 'Run RPC example', done: false },
{ title: 'Ship demo', done: true },
])
.select();
if (error) {
throw new Error(error.message);
}
console.log(data);

Read, update, and delete

Select with filters and ordering:

const { data, error } = await supabase
.from('todos')
.select('*')
.eq('done', false)
.order('id');

Update matching rows:

const { data, error } = await supabase
.from('todos')
.update({ done: true })
.eq('id', 1)
.select();

Delete matching rows:

const { error } = await supabase
.from('todos')
.delete()
.eq('id', 1);

Chain .eq(), .in(), .limit(), and other filter helpers the same way across operations.

RPC

Call a database function by name. Our list_open_todos() returns open todos without repeating filter logic in the app.

const { data, error } = await supabase.rpc('list_open_todos');
if (error) {
throw new Error(error.message);
}
console.log(data);

Functions with parameters map to a second argument:

await supabase.rpc('mark_todo_done', { todo_id: 42 });

Define parameters in SQL (todo_id bigint) and grant execute to service_role when calling RPC from a secret-key backend.

What else matters

  • Row Level Security (RLS) - When enabled on a table, Postgres denies access until policies allow it. The publishable key is subject to RLS; the secret key is not. Add policies per role (anon, authenticated) and operation when you turn RLS on.
  • Auth - Supabase Auth stores users in auth.users. After sign-in, the client JWT includes the authenticated role so policies can use auth.uid() for per-user rows.
  • Migrations - Avoid changing production only via the dashboard. Track SQL in versioned migration files and apply with the Supabase CLI (supabase db push, linked projects) so environments stay aligned.
  • Generated types - Run supabase gen types typescript --project-id <ref> to emit TypeScript types from your schema for a type-safe client.
  • Storage - S3-compatible buckets for files (images, PDFs) with bucket policies analogous to RLS.
  • Realtime - Subscribe to insert/update/delete on tables or channels for live UI updates.
  • Edge Functions - Deno functions for webhooks, light API logic, or tasks that should not live in the database.
  • Local dev - supabase init and supabase start spin up local Postgres and services; useful for offline work, while cloud projects are fastest for a first tutorial.

Demo

Runnable scripts for this post live in the supabase-basics-demo folder in the private demos repository. Get access via code demos.

RAG with OpenAI Embeddings, pgvector and LangChain

June 2, 2026

Retrieval-Augmented Generation (RAG) is a practical pattern: store knowledge as embeddings, retrieve the most relevant chunks with semantic search, then generate an answer grounded in that context.

This guide shows an end-to-end RAG flow with LangChain, OpenAI embeddings, PostgreSQL + pgvector, and an LCEL answer chain. For LangChain basics, see the LangChain overview post. For loaders and splitter choice, see the loaders and chunking post.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • PostgreSQL with pgvector extension enabled
  • npm packages: @langchain/pgvector, @langchain/openai, @langchain/core, @langchain/textsplitters, langchain, pg
npm i @langchain/pgvector @langchain/openai @langchain/core @langchain/textsplitters langchain pg

What are embeddings?

Embeddings are numeric vectors that represent the semantic meaning of text. Similar text should produce vectors that are close in vector space.

In this pipeline:

  • Split source documents into chunks
  • Embed chunks with OpenAIEmbeddings and store them in pgvector via PGVectorStore
  • Embed the user question at query time and retrieve nearest chunks with a LangChain retriever
  • Pass retrieved context into an LCEL chain that calls ChatOpenAI

Chunk documents

Chunking makes retrieval more precise. Instead of embedding one large document, split it into smaller overlapping parts. Start with chunkSize: 800 and chunkOverlap: 120, then adjust based on your document style and answer quality.

import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 800,
chunkOverlap: 120
});
const docs = await splitter.createDocuments(
['RAG combines retrieval and generation. Store chunks as vectors and fetch similar chunks at query time.'],
[{ source: 'notes.md' }]
);

Store chunks in pgvector

Use PGVectorStore from @langchain/pgvector. It creates the table if needed, embeds documents, and stores vectors with metadata.

import pg from 'pg';
import { OpenAIEmbeddings } from '@langchain/openai';
import { PGVectorStore } from '@langchain/pgvector';
const embeddings = new OpenAIEmbeddings({ model: 'text-embedding-3-small' });
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const vectorStore = await PGVectorStore.initialize(embeddings, {
pool,
tableName: 'rag_documents',
columns: {
idColumnName: 'id',
vectorColumnName: 'vector',
contentColumnName: 'content',
metadataColumnName: 'metadata'
},
distanceStrategy: 'cosine'
});
await vectorStore.addDocuments(docs);

Retrieve context

Turn the vector store into a retriever to fetch the top-k relevant chunks for a question:

const retriever = vectorStore.asRetriever({ k: 4 });
const chunks = await retriever.invoke('How does pgvector semantic search work?');

RAG chain with LCEL

Wire retrieval and generation with LCEL. The retriever supplies context; the model answers from that context only.

import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { RunnablePassthrough, RunnableSequence } from '@langchain/core/runnables';
import { ChatOpenAI } from '@langchain/openai';
const prompt = ChatPromptTemplate.fromMessages([
[
'system',
'Answer only from the provided context. If context is insufficient, say you need more data.'
],
['human', 'Context:\n{context}\n\nQuestion: {question}']
]);
const model = new ChatOpenAI({ model: 'gpt-5.5' });
const formatDocs = (documents) =>
documents.map((doc) => doc.pageContent).join('\n\n---\n\n');
const chain = RunnableSequence.from([
{
context: retriever,
question: new RunnablePassthrough()
},
(input) => ({
context: formatDocs(input.context),
question: input.question
}),
prompt,
model,
new StringOutputParser()
]);
const answer = await chain.invoke('How does pgvector semantic search work?');
console.log(answer);

Demo

Runnable scripts for this post live in the rag-openai-embeddings-pgvector-demo folder in the private demos repository. Get access via code demos.

Integration with Hugging Face Inference API

June 1, 2026

Hugging Face hosts thousands of open models for NLP, vision, and other tasks. The Inference API (via Inference Providers) lets you call those models over HTTP. The @huggingface/inference package from huggingface.js is the Node.js client.

Prerequisites

  • Hugging Face account
  • Access token with permission to call Inference (create a token of type Read or Fine-grained with inference access)
  • Node.js version 26
  • @huggingface/inference installed (npm i @huggingface/inference)

Some models (especially image generation) are routed through Inference Providers. Enable providers and billing in account settings when a model requires it.

Client setup

Pass your token to InferenceClient. Read it from the environment in production.

import { InferenceClient } from '@huggingface/inference';
const client = new InferenceClient(process.env.HF_TOKEN);

Summarization

Shrink longer text into a short summary. Pick a model trained for summarization and respect its max input length.

const result = await client.summarization({
model: 'facebook/bart-large-cnn',
inputs: `The tower is 324 metres (1,063 ft) tall, about the same height as an 81-storey building, and the tallest structure in Paris. Its base is square, measuring 125 metres (410 ft) on each side. During its construction, the Eiffel Tower surpassed the Washington Monument to become the tallest man-made structure in the world, a title it held for 41 years until the Chrysler Building in New York City was finished in 1930. It was initially criticized by some artists and intellectuals, but it became a global cultural icon of France and one of the most recognizable structures in the world.`,
parameters: {
max_length: 80,
},
});
console.log(result.summary_text);

Text classification

Assign labels with confidence scores. Common use case: sentiment analysis on short text.

const labels = await client.textClassification({
model: 'distilbert-base-uncased-finetuned-sst-2-english',
inputs: 'I really enjoyed this workshop.',
});
for (const { label, score } of labels) {
console.log(label, score.toFixed(4));
}

Text to image

Generate an image from a text prompt. The client returns a Blob; write it to disk or serve it from your app.

import { writeFileSync } from 'node:fs';
const image = await client.textToImage({
provider: 'hf-inference',
model: 'black-forest-labs/FLUX.1-schnell',
inputs: 'A watercolor painting of a fox in an autumn forest',
parameters: {
num_inference_steps: 4,
},
});
const buffer = Buffer.from(await image.arrayBuffer());
writeFileSync('output.png', buffer);

With provider: 'hf-inference', use a model from the hf-inference catalog for that task (for example black-forest-labs/FLUX.1-schnell). Older Hub repos such as stabilityai/stable-diffusion-2-1 may no longer exist or lack provider mapping.

Image models are often slower and may incur provider usage charges.

Demo

Runnable scripts for each task live in the huggingface-inference-api-demo folder. Get access via code demos.

LLM integration with OpenAI Responses API

May 31, 2026

Large language models (LLMs) understand and generate text from prompts. OpenAI exposes models through the Responses API. The official openai npm package is the practical way to call it from Node.js. This post covers common patterns beyond a single prompt string.

For the Chat Completions API (messages, choices[0].message.content), see the dedicated post. For the same patterns with the Vercel AI SDK (generateText, streamText), see the dedicated post.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • openai package installed (npm i openai)
  • For Markdown output: marked, dompurify, and jsdom (npm i marked dompurify jsdom)

Client setup

Create a client with your API key (read from the environment in production).

import OpenAI from 'openai';
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

The same SDK can target other hosts that implement a compatible API by setting baseURL and apiKey:

const client = new OpenAI({
apiKey: process.env.LLM_API_KEY,
baseURL: 'https://your-gateway.example/v1',
});

Azure OpenAI uses AzureOpenAI instead. Many third-party gateways support Chat Completions only; the examples below use client.responses.*, so confirm your provider supports the Responses API (especially for tools like web search).

Basic integration

Pass a string as input and read output_text from the response.

const response = await client.responses.create({
model: 'gpt-5.5',
input: 'Write a one-sentence bedtime story about a unicorn.',
});
console.log(response.output_text);

System prompt

Use top-level instructions for stable behavior (tone, format, role). They take precedence over casual wording in the user message.

const response = await client.responses.create({
model: 'gpt-5.5',
instructions: 'Reply in one short sentence. Use plain language.',
input: 'Explain what an LLM is.',
});
console.log(response.output_text);

Few-shot prompting

Pass prior turns as an input array with user and assistant roles, then the new user message. Keep task rules in instructions.

const response = await client.responses.create({
model: 'gpt-5.5',
instructions:
'Classify sentiment as exactly one word: positive, negative, or neutral.',
input: [
{ role: 'user', content: 'I love this!' },
{ role: 'assistant', content: 'positive' },
{ role: 'user', content: 'This is awful.' },
{ role: 'assistant', content: 'negative' },
{ role: 'user', content: 'It is fine I guess.' },
],
});
console.log(response.output_text);

Streaming

Set stream: true and handle response.output_text.delta events for incremental text.

const stream = await client.responses.create({
model: 'gpt-5.5',
input: 'List three colors.',
stream: true,
});
for await (const event of stream) {
if (event.type === 'response.output_text.delta') {
process.stdout.write(event.delta);
}
}

Structured output with JSON schema

Constrain the model to JSON matching your schema via text.format. With strict: true, the output should match the schema.

const response = await client.responses.create({
model: 'gpt-5.5',
input: 'The film Inception was directed by Christopher Nolan.',
text: {
format: {
type: 'json_schema',
name: 'movie_summary',
strict: true,
schema: {
type: 'object',
properties: {
title: { type: 'string' },
director: { type: 'string' },
},
required: ['title', 'director'],
additionalProperties: false,
},
},
},
});
const data = JSON.parse(response.output_text);
console.log(data.title, data.director);

For typed parsing with Zod, you can use client.responses.parse() instead of JSON.parse.

Markdown output to HTML

Ask for Markdown in instructions, then convert output_text to HTML and sanitize before rendering (for example with innerHTML in the browser or when storing HTML).

import { marked } from 'marked';
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';
const purify = DOMPurify(new JSDOM('').window);
const response = await client.responses.create({
model: 'gpt-5.5',
instructions: 'Reply in Markdown only. Use a heading and a short bullet list.',
input: 'Explain what an LLM is in three bullet points.',
});
const markdown = response.output_text;
const html = marked.parse(markdown);
const safeHtml = purify.sanitize(html);

Always run DOMPurify.sanitize on model-generated HTML. The model can emit unsafe markup; sanitization strips scripts and other dangerous content.

Web search tool

Enable the built-in web search tool when the answer should use current information from the web.

const response = await client.responses.create({
model: 'gpt-5.5',
tools: [{ type: 'web_search' }],
include: ['web_search_call.action.sources'],
input: 'What was a major tech headline this week? Cite sources briefly.',
});
console.log(response.output_text);

Web search adds latency and tool usage cost. Use a model that supports tools.

Demo

Runnable scripts for each section live in the openai-responses-api-demo folder. Get access via code demos.

Browser automation with Playwright

May 17, 2026

Playwright is a headless browser library for automating browser tasks. Here's the list of some of the features (Playwright equivalents to the Puppeteer post):

  • Turn off headless mode

    import { chromium } from 'playwright';
    const browser = await chromium.launch({
    headless: false
    // ...
    });
  • Resize the viewport to the window size

    const context = await browser.newContext({
    viewport: null
    });
    const page = await context.newPage();
  • Emulate screen how it's shown to the user via the emulateMedia method

    await page.emulateMedia({ media: 'screen' });
  • Save the page as a PDF file with a specified path, format, scale factor, and page range

    await page.pdf({
    path: 'path.pdf',
    format: 'A3',
    scale: 1,
    pageRanges: '1-2',
    printBackground: true
    });
  • Use preexisting user's credentials to skip logging in to some websites. The user data directory is a parent of the Profile Path value from the chrome://version page. Use launchPersistentContext instead of launch + newContext.

    import { chromium } from 'playwright';
    const context = await chromium.launchPersistentContext(
    'C:\\Users\\<USERNAME>\\AppData\\Local\\Google\\Chrome\\User Data',
    { headless: false }
    );
    const page = context.pages()[0] ?? (await context.newPage());
  • Use Chrome instance instead of Chromium via the channel option. Close Chrome before running the script if the profile is in use.

    import { chromium } from 'playwright';
    const browser = await chromium.launch({
    channel: 'chrome'
    // ...
    });
  • Switch to the selected tab

    await page.bringToFront();
  • Get value based on evaluation in the browser page

    const shouldPaginate = await page.evaluate(
    ([param1, param2]) => {
    // ...
    },
    [param1, param2]
    );
  • Get HTML content from the specific element

    const html = await page.locator('.field--text').evaluate(
    (element) => element.outerHTML
    );
  • Wait for a specific selector to be loaded. You can also provide a timeout in milliseconds

    await page.waitForSelector('.success', { timeout: 5000 });
  • Manipulate with a specific element and click on some of the elements

    await page.locator('#header').evaluate(async (headerElement) => {
    // ...
    headerElement
    .querySelectorAll('svg')
    .item(13)
    .parentNode.click();
    });
  • Extend execution timeout for slow evaluate callbacks

    page.setDefaultTimeout(0);
    // or per action:
    await page.locator('#header').evaluate(async (headerElement) => {
    // ...
    }, { timeout: 0 });
  • Manipulate with multiple elements

    await page.locator('.some-class').evaluateAll(async (elements) => {
    // ...
    });
  • Wait for navigation (e.g., form submitting) to be done

    await page.waitForLoadState('networkidle', { timeout: 0 });

    Or wait for navigation triggered by a click:

    await Promise.all([
    page.waitForNavigation({ waitUntil: 'networkidle', timeout: 0 }),
    page.click('button[type="submit"]')
    ]);
  • Trigger hover event on some of the elements

    await page.locator('#header').hover();

    Or dispatch a custom event in the page:

    await page.locator('#header').evaluate((headerElement) => {
    const hoverEvent = new MouseEvent('mouseover', {
    view: window,
    bubbles: true,
    cancelable: true
    });
    headerElement.dispatchEvent(hoverEvent);
    });
  • Expose a function in the browser and use it in evaluate / evaluateAll callbacks (e.g., simulate typing using the window.type function)

    await page.exposeFunction('type', async (selector, text, options) => {
    await page.locator(selector).type(text, options);
    });
    await page.locator('.some-class').evaluateAll(async (elements) => {
    // ...
    await window.type(selector, text, { delay: 0 });
    });
  • Press the Enter button after typing the input field value

    await page.locator(selector).fill(text);
    await page.locator(selector).press('Enter');
  • Open a file chooser and select file for upload

    const fileChooserPromise = page.waitForEvent('filechooser');
    await page.locator(selector).click();
    const fileChooser = await fileChooserPromise;
    const filePath = `C:/Users/<USERNAME>/Downloads/test.jpeg`; // use "/" instead of "\" in file path
    await fileChooser.setFiles(filePath);
  • Remove the value from the input field before typing the new one

    await page.locator(selector).fill(text);

    Or select all and replace:

    await page.locator(selector).click({ clickCount: 3 });
    await page.locator(selector).type(text, options);
  • Pass a variable into evaluate / evaluateAll callbacks via extra arguments

    await page.locator('#element').evaluate(
    async (element, customVariable) => {
    // ...
    },
    customVariable
    );
  • Mock response for the specific request

    await page.route(REDIRECTION_URL, async (route) => {
    await route.fulfill({
    contentType: 'text/html',
    status: 304,
    body: '<body></body>'
    });
    });
  • Intercept page redirections (via route) and open them in new tabs rather than following them in the same tab

    await page.route(REDIRECTION_URL, async (route) => {
    const url = route.request().url();
    await route.fulfill({
    contentType: 'text/html',
    status: 304,
    body: '<body></body>'
    });
    const newPage = await context.newPage();
    await newPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 0 });
    // ...
    await newPage.close();
    });
  • Intercept page response

    page.on('response', async (response) => {
    if (response.url() === RESPONSE_URL) {
    if (response.status() === 200) {
    // ...
    }
    // ...
    }
    });

Demo

Runnable tests for each API pattern live in the browser-automation-playwright-demo folder. Get access via code demos.

Infrastructure as Code (IaC) with Terraform (AWS EC2 Example)

April 28, 2026

Infrastructure as Code (IaC) is a DevOps approach where infrastructure is defined and managed using code instead of manual setup. This makes environments reproducible, version-controlled, and easy to scale.

In this guide, you'll provision an AWS EC2 instance using Terraform.

Requirements

Before starting, install:

  • Terraform
  • AWS CLI

AWS Credentials Setup

  1. Go to IAM → Security credentials in AWS
  2. Create access keys
  3. Configure locally:
aws configure

This stores credentials in:

  • ~/.aws/credentials
  • ~/.aws/config

Project Structure

A simple Terraform setup:

.
├── main.tf
├── variables.tf
├── terraform.tfvars
├── outputs.tf

Provider Configuration

Define your cloud provider in main.tf:

provider "aws" {
profile = "default"
region = "eu-north-1"
}

Variables

Define reusable variables in variables.tf:

variable "instance_name" {
description = "Name tag for EC2 instance"
type = string
default = "MyNewInstance"
}
variable "ec2_instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}

Set values in terraform.tfvars:

instance_name = "MyEC2Name"
ec2_instance_type = "t3.micro"

EC2 Instance Configuration

Add the resource in main.tf:

resource "aws_instance" "app_server" {
ami = "ami-077d1b9f9a1902bbc"
instance_type = var.ec2_instance_type
tags = {
Name = var.instance_name
}
}

You can find AMI IDs in EC2 → Images → AMI Catalog.

Outputs

Expose useful data in outputs.tf:

output "instance_id" {
description = "EC2 instance ID"
value = aws_instance.app_server.id
}
output "instance_public_ip" {
description = "Public IP address"
value = aws_instance.app_server.public_ip
}

Terraform Workflow

Initialize the project:

  • terraform init

Preview changes:

  • terraform plan

Apply changes:

  • terraform apply

Destroy infrastructure:

  • terraform destroy

Important Notes

  • State file

Terraform stores infrastructure state in terraform.tfstate.

Do not commit this file to Git.

  • Remote state (recommended)

For teams, store state in S3 with locking.

  • Idempotency

Running apply multiple times won't recreate resources unnecessarily.

  • Version control

Treat Terraform code like application code.

Demo

Terraform files for this post live in the terraform-aws-ec2 folder. Get access via code demos.

Load and stress testing with Artillery

April 25, 2026

Artillery is a performance testing tool. This post explains types of performance testing and dives into Artillery usage, from configuration to running tests and checking results.

Load and stress testing

Load and stress testing are two types of performance testing used to evaluate how well a system performs under various conditions.

Load testing determines how the system performs under expected user loads. The purpose is to identify performance bottlenecks.

Stress testing assesses how the system performs when loads are heavier than usual. The purpose is to find the limit at which the system fails and to observe how it recovers from such failures.

Prerequisites

  • Artillery available via npx (or installed in project dev dependencies)

  • YAML config file that defines target, phases and scenarios

  • Optional CSV payload file for dynamic test data

Configuration

Artillery test configuration is defined in a YAML file (for example, .artillery/main.yml). It typically contains:

  • target: base URL of the app (http://localhost:3000)
  • phases: load shape over time
  • payload: CSV file for dynamic values
  • scenarios: user flows and weights

For realistic load tests, configure these parts intentionally:

  • Define phases for warm-up, ramp-up, and sustained load.
  • Use a CSV payload when requests require dynamic values (IDs, emails, tokens, and similar).
  • Use weighted scenarios to simulate realistic traffic distribution across endpoints.
config:
target: 'http://localhost:3000'
phases:
- duration: 30
arrivalRate: 5
name: Warm up
- duration: 30
arrivalRate: 5
rampTo: 50
name: Ramp up load
- duration: 30
arrivalRate: 50
name: Sustained load
payload:
path: 'dynamic_data.csv'
fields:
- 'CustomerId'
scenarios:
- name: "Get customer's tracks"
flow:
- get:
url: '/customers/{{ CustomerId }}/tracks'
weight: 4
- name: 'Get customers pdf'
flow:
- get:
url: '/customers/pdf'
weight: 1

Scenario flow and dynamic data

Scenarios define user actions during the test.

In the example:

  • 80% of users run Get customer's tracks (weight: 4)
  • 20% of users run Get customers pdf (weight: 1)

Dynamic data is loaded from .artillery/dynamic_data.csv, and {{ CustomerId }} is injected into request URLs.

Running tests

Load tests can be executed through an npm script:

"scripts": {
"test:load": "artillery run ./.artillery/main.yml"
}

Run the test with:

npm run test:load

You can also run Artillery directly with npx:

npx artillery run ./.artillery/main.yml

Test report

By default, Artillery prints a terminal summary with key metrics such as:

  • failed virtual users and HTTP error/status-code distribution
  • response-time percentiles (especially p95 and p99)
  • median response time (p50)
  • requests per second and total request volume

To save raw results and generate an HTML report:

npx artillery run --output report.json ./.artillery/main.yml
npx artillery report report.json --output report.html

This generates a shareable HTML report with latency percentiles, throughput and error trends.

When reading Artillery reports, focus on these metrics first:

  • Error rate (vusers.failed, non-2xx/3xx codes): the most important health signal. Even small error percentages can indicate instability under load.
  • Tail latency (p95, p99): shows worst-case user experience. Systems can have a good median but still feel slow for a significant group of users.
  • Median latency (p50/median): useful for baseline responsiveness, but should always be evaluated together with p95/p99.
  • Throughput (http.requests, requests/sec): confirms how much traffic the system handled during the test.
  • Trend over time (intermediate snapshots): helps identify whether performance degrades during ramp-up or sustained load.

In short: start with reliability (errors), then check latency percentiles (especially p95/p99), and finally validate throughput and time-based stability.

2024

HTTP timeout with Axios

September 26, 2024

Setting up a timeout for HTTP requests can prevent the connection from hanging forever, waiting for the response. It can be set on the client side to improve user experience, and on the server side to improve inter-service communication.

axios package provides a timeout parameter for this functionality.

const HTTP_TIMEOUT = 3000;
const URL = 'https://www.google.com:81';
(async () => {
try {
await axios(URL, {
timeout: HTTP_TIMEOUT
});
} catch (error) {
console.error('Request timed out', error.cause);
}
})();

Use this snippet also to simulate aborted requests.

Demo

Runnable code for this post lives in the axios-http-demo folder. Get access via code demos.

RabbitMQ container with Docker Compose

September 3, 2024

For Compose basics, see the Docker Compose overview. This post covers a RabbitMQ broker with the management UI.

Prerequisites

  • Docker Compose installed

Configuration

The following configuration spins up the RabbitMQ container with the management UI tool.

The connection string for the RabbitMQ broker with local virtual host is amqp://localhost:5672/local.

RabbitMQ management UI is available at the http://localhost:15672 link. Default credentials are guest as username and guest as password.

# docker-compose.yml
services:
rabbitmq:
image: rabbitmq:3-management
ports:
- 5672:5672
- 15672:15672
environment:
- RABBITMQ_DEFAULT_VHOST=local
volumes:
- 'rabbitmq_data:/data'
volumes:
rabbitmq_data:

Run the following command to spin up the container.

docker compose up

Demo

Docker Compose files and setup scripts for this post live in the rabbitmq-docker-compose folder. Get access via code demos.

Simulating keyboard typing with JavaScript

August 21, 2024

Simulating keyboard typing in JavaScript can be useful for automating tasks or testing applications. The KeyboardEvent API allows developers to trigger keyboard events programmatically.

Examples

  • The snippet below simulates pressing the Ctrl + Enter command. The bubbles flag ensures the event moves up through the DOM, so any elements higher up in the document can also detect and respond to it.
const event = new KeyboardEvent('keydown', {
key: 'Enter',
ctrlKey: true,
bubbles: true
});
document.dispatchEvent(event);
  • The snippet below simulates pressing the Shift + Enter command on a specific input field.
const event = new KeyboardEvent('keydown', {
key: 'Enter',
shiftKey: true,
bubbles: true
});
document.querySelector('input').dispatchEvent(event);

Demo

Runnable scripts for this post live in the simulate-keyboard-typing-javascript-demo folder. Get access via code demos.

Profiling Node.js apps with Chrome DevTools profiler

July 5, 2024

Profiling refers to analyzing and measuring an application's performance characteristics.

Profiling helps identify performance bottlenecks in a Node.js app, such as CPU-intensive tasks like cryptographic operations, image processing, or complex calculations.

This post covers running a profiler for various Node.js apps in Chrome DevTools.

Prerequisites

  • Google Chrome installed

  • Node.js app bootstrapped

Setup

  • Run node --inspect app.js to start the debugger.

  • Open chrome://inspect, click Open dedicated DevTools for Node and then navigate to the Performance tab. Start recording.

  • Run load testing via autocannon package using the following command format npx autocannon <COMMAND>.

  • Stop recording in Chrome DevTools.

Profiling

On Perfomance tab in Chrome DevTools open Bottom-Up subtab to identify which functions consume the most time.

Look for potential performance bottlenecks, such as synchronous functions for hashing (pbkdf2Sync) or file system operations (readFileSync).

Load and stress testing with k6

May 10, 2024

k6 is a performance testing tool. This post explains types of performance testing and dives into k6 usage, from configuration to running tests.

Load and stress testing

Load and stress testing are two types of performance testing used to evaluate how well a system performs under various conditions.

Load testing determines how the system performs under expected user loads. The purpose is to identify performance bottlenecks.

Stress testing assesses how the system performs when loads are heavier than usual. The purpose is to find the limit at which the system fails and to observe how it recovers from such failures.

Prerequisites

  • k6 installed

  • Script (Node.js) file with configuration and execution function

Configuration

Configuration is stored inside options variable, which allows you to set up different testing scenarios:

  • constant user load, the number of virtual users (vus) remains constant throughout the test period
export const options = {
vus: 30,
duration: '10m'
};
  • variable user load, the number of users increases and decreases over time
export const options = {
stages: [
{
duration: '1m',
target: 30
},
{
duration: '10m',
target: 30
},
{
duration: '5m',
target: 0
}
]
};

Environment variables can be passed through the command line and are accessible within the script via the __ENV object.

k6 -e TOKEN=token run script.js

Execution function

This function defines what virtual users will do during the test. This function is called for each virtual user and typically includes steps that simulate user actions on the app.

export default function() {
http.get(URL);
// Add more actions as required
}

Test report

k6 generates a report that provides detailed insights into various benchmarks, such as the number of virtual users, requests per second, request durations and error rates.

Example

This example utilizes k6 to conduct a load test using a variable user load approach:

  • User simulation: The script ramps up to 1,000 users, maintains that level to simulate sustained traffic, and gradually reduces to zero.

  • Request handling: During the test, each virtual user sends a POST request to an API, with pauses between requests to mimic real user behavior.

  • Performance insights: After the test, k6 provides a report that shows key information, such as how fast the app responds and how many requests fail.

Run it via k6 -e TOKEN=1234 run script.js command.

// script.js
import { check, sleep } from 'k6';
import { scenario } from 'k6/execution';
import http from 'k6/http';
export const options = {
stages: [
// Ramp up to 1000 users over 10 minutes
{
duration: '10m',
target: 1000
},
// Hold 1000 users for 30 minutes
{
duration: '30m',
target: 1000
},
// Ramp down to 0 users over 5 minutes
{
duration: '5m',
target: 0
}
]
};
export default () => {
const response = http.post(
URL,
JSON.stringify({
iteration: scenario.iterationInTest
}),
{
headers: {
Authorization: __ENV.TOKEN,
'Content-Type': 'application/json'
}
}
);
check(response, {
'response status was 200': (res) => res.status === 200
});
sleep(1);
};

Demo

Runnable scripts for each section live in the load-stress-testing-k6-demo folder. Get access via code demos.

Node Version Manager (nvm) overview

May 9, 2024

nvm facilitates switching between different Node versions across projects. This post covers nvm-sh on macOS and Linux. For Windows, see the nvm for Windows post.

As of mid-2026, Node.js 24 is Active LTS, 22 is Maintenance LTS, and 26 is the Current release.

Installation

To install nvm, execute the following commands in your terminal. This example uses zsh, but the process is similar for other shells like bash.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | bash
source ~/.zshrc

Version management

  • Install the specific version. Including the v prefix is optional.

    nvm install v24.16.0
  • Install the latest version

    nvm install node
  • Install the latest Active LTS release

    nvm install --lts
  • Install the latest one for the specified major version

    nvm install 24
  • Switch to a specific installed version

    nvm use 24
  • Add .nvmrc file inside the project directory and run nvm use command to use the specified installed version.

    v24.16.0
  • Get the list of locally installed versions

    nvm ls
  • Get the list of available versions for installation

    nvm ls-remote

Debugging Node.js apps with Chrome DevTools debugger

April 12, 2024

Debugging with a debugger and breakpoints is recommended rather than using console logs. Chrome provides a built-in debugger for JavaScript-based apps.

This post covers configuring and running a debugger for various Node.js apps in Chrome DevTools.

Prerequisites

  • Google Chrome installed

  • Node.js app bootstrapped

Setup

Open chrome://inspect, click Open dedicated DevTools for Node and open the Connection tab. You should see a list of network endpoints for debugging.

Run node --inspect-brk app.js to start the debugger, it will log the debugger network. Choose the network in Chrome to open the debugger in Chrome DevTools.

Debugging basics

The variables tab shows local and global variables during debugging.

The step over next function call option goes to the following statement in the codebase, while the step into next function call option goes deeper into the current statement.

Add logs in the debug console via logpoints by selecting the specific part of the codebase so it logs when the selected codebase executes.

Sending e-mails with SendGrid

March 30, 2024

To send e-mails in a production environment, use services like Sendgrid.

Verify the e-mail address on the Sender Management page and create the SendGrid API key on the API keys page.

import nodemailer from 'nodemailer';
(async () => {
const emailConfiguration = {
auth: {
user: process.env.EMAIL_USERNAME, // 'apikey'
pass: process.env.EMAIL_PASSWORD
},
host: process.env.EMAIL_HOST, // 'smtp.sendgrid.net'
port: process.env.EMAIL_PORT, // 465
secure: process.env.EMAIL_SECURE // true
};
const transport = nodemailer.createTransport(emailConfiguration);
const info = await transport.sendMail({
from: '"Sender" <sender@example.com>',
to: 'recipient1@example.com, recipient2@example.com',
subject: 'Subject',
text: 'Text',
html: '<b>Text</b>'
});
console.log('Message sent: %s', info.messageId);
})();

Demo

Runnable code for this post lives in the emails-sendgrid-demo folder. Get access via code demos.

MongoDB containers with Docker Compose

February 2, 2024

For Compose basics, see the Docker Compose overview. This post covers MongoDB with Mongo Express.

Prerequisites

  • Docker Compose installed

Configuration

The following configuration spins up the MongoDB container with the UI tool (Mongo Express).

The connection string for the MongoDB database is mongodb://localhost:27018.

Mongo Express is available at the http://localhost:8082 link. Use the below Basic auth credentials to log in to Mongo Express.

# docker-compose.yml
services:
mongo:
image: 'mongo:7.0.5'
ports:
- 27018:27017
volumes:
- my-data:/var/lib/mongodb/data
mongo-express:
image: 'mongo-express:1.0.2'
ports:
- 8082:8081
environment:
ME_CONFIG_BASICAUTH_USERNAME: username
ME_CONFIG_BASICAUTH_PASSWORD: password
volumes:
my-data:

Run the following command to spin up the containers.

docker compose up

Demo

Docker Compose files for this post live in the mongodb-docker-compose folder. Get access via code demos.

Web scraping with cheerio

January 19, 2024

Web scraping means extracting data from websites. This post covers extracting data from the page's HTML tags.

Prerequisites

  • cheerio package is installed

  • HTML page is retrieved via an HTTP client

Usage

  • create a scraper object with load method by passing HTML content as an argument

    • set decodeEntities option to false to preserve encoded characters (like &) in their original form
    const $ = load('<div><!-- HTML content --></div>', { decodeEntities: false });
  • find DOM elements by using CSS-like selectors

    const items = $('.item');
  • iterate through found elements using each method

    items.each((index, element) => {
    // ...
    });
  • access element content using specific methods

    • text - $(element).text()

    • HTML - $(element).html()

    • attributes

      • all - $(element).attr()
      • specific one - $(element).attr('href')
    • child elements

      • first - $(element).first()
      • last - $(element).last()
      • all - $(element).children()
      • specific one - $(element).find('a')
    • siblings

      • previous - $(element).prev()
      • next - $(element).next()

Disclaimer

Please check the website's terms of service before scraping it. Some websites may have terms of service that prohibit such activity.

Demo

Runnable code for this post lives in the scraping-cheerio-demo folder. Get access via code demos.

2023

Integration with GitHub GraphQL API

December 22, 2023

GitHub provides GraphQL API to create integrations, retrieve data, and automate workflows.

Prerequisites

  • GitHub token (Settings Developer Settings Personal access tokens)

Integration

Below is an example of retrieving sponsorable users by location.

export async function getUsersBy(location) {
return fetch('https://api.github.com/graphql', {
method: 'POST',
body: JSON.stringify({
query: `query {
search(type: USER, query: "location:${location} is:sponsorable", first: 100) {
edges {
node {
... on User {
bio
login
viewerCanSponsor
}
}
}
userCount
}
}`
}),
headers: {
ContentType: 'application/json',
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
}
})
.then((response) => response.json())
.then((response) => response.data?.search?.edges || []);
}

Demo

Runnable Node.js script for this post lives in the github-graphql-api-nodejs-demo folder. Get access via code demos.

Web scraping with jsdom

December 14, 2023

Web scraping means extracting data from websites. This post covers extracting data from the page's HTML when data is stored in JavaScript variable or stringified JSON.

The scraping prerequisite is retrieving an HTML page via an HTTP client.

Examples

The example below moves data into a global variable, executes the page scripts and accesses the data from the global variable.

import jsdom from 'jsdom';
fetch(URL)
.then((res) => res.text())
.then((response) => {
const dataVariable = 'someVariable.someField';
const html = response.replace(dataVariable, `var data=${dataVariable}`);
const dom = new jsdom.JSDOM(html, {
runScripts: 'dangerously',
virtualConsole: new jsdom.VirtualConsole()
});
console.log('data', dom?.window?.data);
});

The example below runs the page scripts, and access stringified JSON data.

import jsdom from 'jsdom';
fetch(URL)
.then((res) => res.text())
.then((response) => {
const dom = new jsdom.JSDOM(response, {
runScripts: 'dangerously',
virtualConsole: new jsdom.VirtualConsole()
});
const data = dom?.window?.document?.getElementById('someId')?.value;
console.log('data', JSON.parse(data));
});

Disclaimer

Please check the website's terms of service before scraping it. Some websites may have terms of service that prohibit such activity.

Demo

Runnable Node.js scripts for this post live in the scraping-jsdom-demo folder. Get access via code demos.

License key verification with Gumroad API

November 16, 2023

Gumroad allows verifying license keys via API calls to limit the usage of the keys. It can be helpful to prevent the redistribution of products like desktop apps.

Allow generating a unique license key per sale in product settings, and the product ID will be shown there. Below is the code snippet for verification.

try {
const requestBody = new URLSearchParams();
requestBody.append('product_id', process.env.PRODUCT_ID);
requestBody.append('license_key', process.env.LICENSE_KEY);
requestBody.append('increment_uses_count', true);
const response = await fetch('https://api.gumroad.com/v2/licenses/verify', {
method: 'POST',
body: requestBody
});
const data = await response.json();
if (data.purchase?.test) {
console.log('Skipping verification for test purchase');
return;
}
const verificationLimit = Number(process.env.VERIFICATION_LIMIT);
if (data.uses >= verificationLimit + 1) {
throw new Error('Verification limit exceeded');
}
if (!data.success) {
throw new Error(data.message);
}
} catch (error) {
if (error?.response?.status === 404) {
console.log("License key doesn't exist");
return;
}
console.log('Verifying license key failed', error);
}

Demo

Runnable code for this post lives in the license-key-verification-gumroad-api-demo folder. Get access via code demos.

Creating a custom GPT version of ChatGPT

November 11, 2023

Creating a custom GPT agent is available to ChatGPT plus users. This post covers the main steps from creation to publishing.

Creation

Open the Explore GPTs tab and choose the Create option.

Write a description of what agent you would like to create.

GPT builder will also propose a GPT name and generate a profile picture.

Refine the GPT context with the builder. Choose interaction style and personalization for the agent.

Knowledge base

Upload files with knowledge data in the Configure tab.

Use files in formats like JSON, PDF, and CSV.

Using external API

Create a new action in the Configure tab by entering OpenAPI docs in the Schema field.

Enter schema in JSON or YAML format or import it from the URL, and ensure it contains the server's URL configured.

Set Authentication for the provided API and test the created action via the Test button.

Security

Add a rule not to expose internal instructions so other users can't copy your configuration.

Add a rule not to expose internal instructions if a user asks for it, and answer with "Sorry, it's not possible."

Publishing

To make your GPT publicly available in the GPT Store, you need to verify the website domain.

Open Settings & Beta Builder profile and verify the new domain for the website. You'll get TXT value, which you need to configure on your domain service like Namecheap, using @ as the host value.

Once you verified the website, click the Save Public Confirm buttons to publish your new GPT.

Examples

PDF generation with Gotenberg

November 4, 2023

Gotenberg is a Docker-based stateless API for PDF generation from HTML and Markdown files.

To get started, configure Docker Compose and run docker compose up.

services:
gotenberg:
image: gotenberg/gotenberg:7
ports:
- 3000:3000

API is available on http://localhost:3000 address.

Run the following commands to generate PDFs

  • from the given URL

    curl \
    --request POST 'http://localhost:3000/forms/chromium/convert/url' \
    --form 'url="https://sparksuite.github.io/simple-html-invoice-template/"' \
    --form 'pdfFormat="PDF/A-1a"' \
    -o curl-url-response.pdf
  • from the given HTML file

    curl \
    --request POST 'http://localhost:3000/forms/chromium/convert/html' \
    --form 'files=@"./index.html"' \
    --form 'pdfFormat="PDF/A-1a"' \
    -o curl-html-response.pdf

PDF/A-1a format is used for the long-term preservation of electronic documents, ensuring that documents can be accessed and read even as technology changes.

Demo

Runnable scripts for each conversion approach live in the pdf-generation-gotenberg-demo folder. Get access via code demos.

Identifying missing variables in Handlebars templates

November 3, 2023

Handlebars is a template engine that can create server-side views, e-mail templates, and invoice templates by injecting JSON data into HTML.

Resolving all variables in a Handlebars template is essential to maintain the accuracy of the displayed information and to prevent incomplete content or layout problems.

The following snippet checks for missing variables by overriding the default nameLookup function. It logs a warning for unresolved variables and sets the default value, empty string, in this case.

const WARNING_LEVEL = 'warning';
const originalNameLookup = Handlebars.JavaScriptCompiler.prototype.nameLookup;
Handlebars.JavaScriptCompiler.prototype.nameLookup = function (
parent,
name,
type
) {
if (type === 'context') {
const messageLog = JSON.stringify({
message: `Variable is not resolved in the template: ${name}`,
level: WARNING_LEVEL
});
return `${parent} && ${parent}.${name} ? ${parent}.${name} : (console.log(${messageLog}), '')`;
}
return originalNameLookup.call(this, parent, name, type);
};
const result = Handlebars.compile(template)(data);

Demo

Runnable Handlebars scripts for this post live in the handlebars-template-missing-variables-demo folder. Get access via code demos.

Extending outdated TypeScript package declarations

November 2, 2023

Extending package declarations locally is one of the options for outdated package typings.

Create a declaration file .d.ts (e.g., handlebars.d.ts), and put it inside the src directories.

Find the exact name of the package namespace inside the node_modules types file (e.g. handlebars/types/index.d.ts).

Extend the found namespace with your needed properties, like classes, functions, etc.

// handlebars.d.ts
declare namespace Handlebars {
export class JavaScriptCompiler {
public nameLookup(
parent: string,
name: string,
type: string
): string | string[];
}
export function doSomething(name: string): void;
// ...
}

Bun overview

September 11, 2023

Bun is a JavaScript runtime built on JavaScriptCore (the engine behind Safari). It is designed for speed and developer experience (DX), bundling many tools into a single binary.

Some of the features include

  • Built-in bundler
  • Built-in test runner
  • Node.js-compatible package manager, compatible with existing npm packages
  • Compatibility with Node.js native modules like fs, path, etc.
  • TypeScript support, run TypeScript files with no extra configuration
  • Built-in watch mode and hot reloading
  • Support for both ES modules and CommonJS modules, both can be used in the same file
  • Native SQLite driver
  • Built-in PostgreSQL, MySQL, and Redis clients

Installation

Install on macOS and Linux:

curl -fsSL https://bun.com/install | bash

Install on Windows (PowerShell):

powershell -c "irm bun.com/install.ps1|iex"

Other options include npm install -g bun, Homebrew, Scoop, and Docker. See the installation docs for details.

Update the current version with the bun upgrade command and check the current version with bun --version.

Run bun --help to see what CLI options are available.

Initialize an empty project via the bun init command.

The init command bootstraps a "hello world" example with package.json, a text lock file (bun.lock), and tsconfig.json.

Bundler

The bundler can be used via the CLI (bun build) or the Bun.build() API.

await Bun.build({
entrypoints: ['./index.ts'],
outdir: './build'
});

Below is an example for CLI usage. Run bun build --help to see all available options.

bun build --target=bun ./index.ts --outdir=./build

Build a single executable using the --compile flag.

bun build ./index.ts --compile --outfile myapp

Bun can also bundle full-stack apps by importing HTML from server code and embed frontend assets into a standalone binary.

Package manager

Install packages from package.json via the bun install command.

Install additional npm packages via the bun add command (e.g., bun add zod). To install dev dependencies, run bun add with the --dev option (e.g., bun add zod --dev).

Remove dependencies via the bun remove command (e.g., bun remove zod).

Running scripts

  • Run a specific script via the bun <SCRIPT PATH>.ts command
  • Auto-install and run packages locally via the bunx command (e.g., bunx cowsay "Hello world")
  • Run a custom npm script from package.json via the bun run <SCRIPT NAME> command

Watch mode

  • Hot reloading via bun --hot index.ts without restarting the process
  • Watch mode via bun --watch index.ts with restarting the process

File system

Write into a file using the Bun.write method.

await Bun.write('./output.txt', 'Lorem ipsum');

Environment variables

  • Access environment variables via Bun.env, process.env, or import.meta.env
  • Store variables in .env files, like .env, .env.production, .env.local
  • Print all current environment variables (including values loaded from .env) via bun --print process.env

HTTP server

Create a server with the following code.

const port = Number(Bun.env.PORT) || 3000;
const server = Bun.serve({
port,
fetch() {
return new Response('Welcome to Bun!');
}
});
console.log(`Listening on port ${server.port}`);

Frameworks

Elysia (Bun framework)

Install packages via bun add elysia @elysiajs/openapi, write the initial server, and run it via bun server.ts.

// server.ts
import { Elysia } from 'elysia';
import { openapi } from '@elysiajs/openapi';
const port = Number(Bun.env.PORT) || 8081;
new Elysia()
.use(openapi())
.get('/posts/:id', ({ params: { id } }) => id)
.listen(port);

OpenAPI docs are served at /openapi by default.

Express

Install the express package via bun add express, write the initial server, and run it via bun server.ts.

// server.ts
import express from 'express';
const app = express();
const port = Number(Bun.env.PORT) || 3001;
app.get('/', (req, res) => {
res.send('Hello world');
});
app.listen(port, () => {
console.log(`Listening on port ${port}`);
});

Debugging

Install the extension Bun for Visual Studio Code by Oven and run the Bun: Debug file command from the command palette. Execution will pause at the breakpoint.

Testing

Bun supports basic mocking and assertion functions. Run existing tests via bun run <TEST SCRIPT NAME> (e.g., bun run test:unit).

Below is an example of a basic test assertion and mocking using the bun:test module.

import { describe, expect, it, mock } from 'bun:test';
import { add } from './addition-service';
import { calculate } from './calculation-service';
describe('Calculation service', () => {
it('should return calculated value', async () => {
const result = calculate();
expect(result).toEqual(5);
});
it('should return mocked value', async () => {
mock.module('./addition-service', () => {
return {
add: () => 3
};
});
const result = add();
expect(result).toEqual(3);
});
});

Run unit tests via the bun test command. Re-run tests when files change via bun test --watch.

SQLite database

Below is a basic example of SQLite driver usage.

import { Database } from 'bun:sqlite';
const database = new Database('database.sqlite');
const query = database.query("SELECT 'hello world' as message;");
console.log(query.get());
database.close();

Demo

Runnable Bun scripts for this post live in the bun-overview-demo folder. Get access via code demos.

Integration with Notion API

September 9, 2023

Notion is a versatile workspace tool combining note-taking, task management, databases, and collaboration features into a single platform.

It also supports integration with Notion content, facilitating tasks such as creating pages, retrieving a block, and filtering database entries via API.

Prerequisites

  • Notion account
  • Generated Integration token (Settings & Members Connections Develop or manage integrations New integration)
  • Notion database ID (open database as full page, extract database ID from the URL (https://notion.so/<USERNAME>/<DATABASE_ID>?v=v))
  • Added Notion connection (three dots (...) menu Add Connections choose created integration)
  • @notionhq/client package installed

Integration

Below is an example of interacting with Notion API to create the page (within the chosen database) with icon, cover, properties, and child blocks.

const { Client } = require('@notionhq/client');
const notion = new Client({ auth: process.env.NOTION_INTEGRATION_TOKEN });
const response = await notion.pages.create({
parent: {
type: 'database_id',
database_id: process.env.NOTION_DATABASE_ID
},
icon: {
type: 'emoji',
emoji: '🆗'
},
cover: {
type: 'external',
external: {
url: 'https://cover.com'
}
},
properties: {
Name: {
title: [
{
type: 'text',
text: {
content: 'Some name'
}
}
]
},
Score: {
number: 42
},
Tags: {
multi_select: [
{
name: 'A'
},
{
name: 'B'
}
]
},
Generation: {
select: {
name: 'I'
}
}
// other properties
},
children: [
{
object: 'block',
type: 'bookmark',
bookmark: {
url: 'https://bookmark.com'
}
}
]
});

Upgrading React Native app to Android 13+

August 28, 2023

There is a requirement to upgrade Android apps (hosted on Google Play Store) to target Android 13. This post covers steps to upgrade to the React Native 0.72+ version.

Automatic approach

Use the npx react-native upgrade command to make code updates to the latest version.

Manual approach

Use Upgrade helper to manually change the code. It shows the differences between boilerplates for the selected versions (e.g., 0.71.13 and 0.72.4).

Fill out the package name (e.g., com.someapp). App name can stay empty, ignore it (rndiffapp) in the code changes.

Advertising ID

Enable it on the Google Play Console app page by selecting Policy and programs App content option. Add it as a permission in the AndroidManifest.xml file.

<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>

Browser automation with Puppeteer

August 26, 2023

Puppeteer is a headless browser for automating browser tasks. Here's the list of some of the features:

  • Turn off headless mode

    const browser = await puppeteer.launch({
    headless: false
    // ...
    });
  • Resize the viewport to the window size

    const browser = await puppeteer.launch({
    // ...
    defaultViewport: null
    });
  • Emulate screen how it's shown to the user via the emulateMediaType method

    await page.emulateMediaType('screen');
  • Save the page as a PDF file with a specified path, format, scale factor, and page range

    await page.pdf({
    path: 'path.pdf',
    format: 'A3',
    scale: 1,
    pageRanges: '1-2',
    printBackground: true
    });
  • Use preexisting user's credentials to skip logging in to some websites. The user data directory is a parent of the Profile Path value from the chrome://version page.

    const browser = await puppeteer.launch({
    userDataDir:
    'C:\\Users\\<USERNAME>\\AppData\\Local\\Google\\Chrome\\User Data',
    args: []
    });
  • Use Chrome instance instead of Chromium by utilizing the Executable Path from the chrome://version URL. Close Chrome browser before running the script

    const browser = await puppeteer.launch({
    executablePath: puppeteer.executablePath('chrome')
    // ...
    });
  • Switch to the selected tab

    await page.bringToFront();
  • Get value based on evaluation in the browser page

    const shouldPaginate = await page.evaluate(
    (param1, param2) => {
    // ...
    },
    param1,
    param2
    );
  • Get HTML content from the specific element

    const html = await page.evaluate(
    () => document.querySelector('.field--text').outerHTML
    );
  • Wait for a specific selector to be loaded. You can also provide a timeout in milliseconds

    await page.waitForSelector('.success', { timeout: 5000 });
  • Manipulate with a specific element and click on some of the elements

    await page.$eval('#header', async (headerElement) => {
    // ...
    headerElement
    .querySelectorAll('svg')
    .item(13)
    .parentNode.click();
    });
  • Extend execution of the $eval method

    const browser = await puppeteer.launch({
    // ...
    protocolTimeout: 0
    });
  • Manipulate with multiple elements

    await page.$$eval('.some-class', async (elements) => {
    // ...
    });
  • Wait for navigation (e.g., form submitting) to be done

    await page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 0 });
  • Trigger hover event on some of the elements

    await page.$eval('#header', async (headerElement) => {
    const hoverEvent = new MouseEvent('mouseover', {
    view: window,
    bubbles: true,
    cancelable: true
    });
    headerElement.dispatchEvent(hoverEvent);
    });
  • Expose a function in the browser and use it in $eval and $$eval callbacks (e.g., simulate typing using the window.type function)

    await page.exposeFunction('type', async (selector, text, options) => {
    await page.type(selector, text, options);
    });
    await page.$$eval('.some-class', async (elements) => {
    // ...
    window.type(selector, text, { delay: 0 });
    });
  • Press the Enter button after typing the input field value

    await page.type(selector, `${text}${String.fromCharCode(13)}`, options);
  • Open a file chooser and select file for upload

    const [fileChooser] = await Promise.all([page.waitForFileChooser(), page.click(selector)]);
    const filePath = `C:/Users/<USERNAME>/Downloads/test.jpeg`; // use "/" instead of "\" in file path
    await fileChooser.accept([filePath]);
  • Remove the value from the input field before typing the new one

    await page.click(selector, { clickCount: 3 });
    await page.type(selector, text, options);
  • Expose a variable in the browser by passing it as the third argument for $eval and $$eval methods and use it in $eval and $$eval callbacks

    await page.$eval(
    '#element',
    async (element, customVariable) => {
    // ...
    },
    customVariable
    );
  • Mock response for the specific request

    await page.setRequestInterception(true);
    page.on('request', async function(request) {
    const url = request.url();
    if (url !== REDIRECTION_URL) {
    return request.continue();
    }
    await request.respond({
    contentType: 'text/html',
    status: 304,
    body: '<body></body>'
    });
    });
  • Intercept page redirections (via interceptor) and open them in new tabs rather than following them in the same tab

    await page.setRequestInterception(true);
    page.on('request', async function(request) {
    const url = request.url();
    if (url !== REDIRECTION_URL) {
    return request.continue();
    }
    await request.respond({
    contentType: 'text/html',
    status: 304,
    body: '<body></body>'
    });
    const newPage = await browser.newPage();
    await newPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 0 });
    // ...
    await newPage.close();
    });
  • Intercept page response

    page.on('response', async (response) => {
    if (response.url() === RESPONSE_URL) {
    if (response.status() === 200) {
    // ...
    }
    // ...
    }
    });

Demo

Runnable tests for each API pattern live in the browser-automation-puppeteer-demo folder. Get access via code demos.

cURL basics

August 11, 2023

cURL is a command line tool for interacting with servers, it can be used in bash scripts to automate some workflows. This post covers primary usage with examples.

  • Send an HTTP GET request to the server
curl ipv4.icanhazip.com
  • Get only the response headers
curl -I ipv4.icanhazip.com
  • Send POST requests with the request body and headers
curl -X POST https://api.gumroad.com/v2/licenses/verify \
-d "product_id=product-id" \
-d "license_key=license-key"
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "What is cURL?"}]
}'
  • Use the i option to include the headers in the response
curl -i -X POST https://api.gumroad.com/v2/licenses/verify \
-d "product_id=product-id" \
-d "license_key=license-key"
  • Use the s option to hide all the logs during the request
curl -s -X POST https://api.gumroad.com/v2/licenses/verify \
-d "product_id=product-id" \
-d "license_key=license-key"
  • Use the v option for verbose logs during the request
curl -v -X POST https://api.gumroad.com/v2/licenses/verify \
-d "product_id=product-id" \
-d "license_key=license-key"
  • Retrieve bash script and run it locally
curl https://raw.githubusercontent.com/creationix/nvm/master/install.sh | bash
  • Trigger specific endpoint inside Kubernetes cronjob
# ...
containers:
- name: cleanup
# ...
command:
- /bin/sh
- -ec
- 'curl "https://some-service.com/cleanup"'

AI bulk image upscaler with Node.js

August 4, 2023

Image upscaling can be done using Real-ESRGAN, a super-resolution algorithm. Super-resolution is the process of increasing the resolution of the image.

Real-ESRGAN provides Linux, Windows and MacOS executable files and models for Intel/AMD/Nvidia GPUs.

The snippet below demonstrates bulk image upscaling with scale factor 4 and using the realesrgan-x4plus-anime model.

(async () => {
const inputDirectory = path.resolve(path.join(__dirname, 'pictures'));
const outputDirectory = path.resolve(
path.join(__dirname, 'pictures_upscaled')
);
const modelsPath = path.resolve(path.join(__dirname, 'resources', 'models'));
const execName = 'realesrgan-ncnn-vulkan';
const execPath = path.resolve(
path.join(__dirname, 'resources', getPlatform(), 'bin', execName)
);
const scaleFactor = 4;
const modelName = 'realesrgan-x4plus-anime';
if (!fs.existsSync(outputDirectory)) {
await fs.promises.mkdir(outputDirectory, { recursive: true });
}
const commands = [
'-i',
inputDirectory,
'-o',
outputDirectory,
'-s',
scaleFactor,
'-m',
modelsPath,
'-n',
modelName
];
const upscaler = spawn(execPath, commands, {
cwd: undefined,
detached: false
});
upscaler.stderr.on('data', (data) => {
console.log(data.toString());
});
await timers.setTimeout(600 * 1000);
})();

Demo

Runnable code for this post lives in the upscaler-demo folder. Get access via code demos.

Publishing Electron apps to GitHub with Electron Forge

July 19, 2023

Releasing Electron desktop apps can be automated with Electron Forge and GitHub Actions. This post covers the main steps for automation.

Prerequisites

  • bootstrapped Electron app
  • GitHub personal access token (with repo and write:packages permissions) as a GitHub Action secret (GH_TOKEN)

Setup

Run the following commands to configure Electron Forge for the app release.

npm i @electron-forge/cli @electron-forge/publisher-github -D
npm i electron-squirrel-startup
npx electron-forge import

The last command should install the necessary dependencies and add a configuration file.

Update the forge.config.js file with the bin field containing the app name and ensure the GitHub publisher points to the right repository.

Put Windows and MacOS icons paths in the packagerConfig.icon field, Windows supports ico files with 256x256 resolution, and MacOS supports icns icons with 512x512 resolution (1024x1024 for Retina displays). Linux supports png icons with 512x512 resolution, also include its path in the BrowserWindow constructor config within the icon field.

// forge.config.js
const path = require('path');
module.exports = {
packagerConfig: {
asar: true,
icon: path.join(process.cwd(), 'main', 'build', 'icon')
},
rebuildConfig: {},
makers: [
{
name: '@electron-forge/maker-squirrel',
config: {
bin: 'Electron Starter'
}
},
{
name: '@electron-forge/maker-dmg',
config: {
bin: 'Electron Starter'
}
},
{
name: '@electron-forge/maker-deb',
config: {
bin: 'Electron Starter',
options: {
icon: path.join(process.cwd(), 'main', 'build', 'icon.png')
}
}
},
{
name: '@electron-forge/maker-rpm',
config: {
bin: 'Electron Starter',
icon: path.join(process.cwd(), 'main', 'build', 'icon.png')
}
}
],
plugins: [
{
name: '@electron-forge/plugin-auto-unpack-natives',
config: {}
}
],
publishers: [
{
name: '@electron-forge/publisher-github',
config: {
repository: {
owner: 'delimitertech',
name: 'electron-starter'
},
prerelease: true
}
}
]
};

Upgrade the package version before releasing the app. The npm script for publishing should use publish command. Set productName field to the app name.

// package.json
{
// ...
"version": "1.0.1",
"scripts": {
// ...
"publish": "electron-forge publish"
},
"productName": "Electron Starter"
}

GitHub Action workflow for manually releasing the app for Linux, Windows, and MacOS should contain the below configuration.

# .github/workflows/release.yml
name: Release app
on:
workflow_dispatch:
jobs:
build:
strategy:
matrix:
os:
[
{ name: 'linux', image: 'ubuntu-latest' },
{ name: 'windows', image: 'windows-latest' },
{ name: 'macos', image: 'macos-latest' },
]
runs-on: ${{ matrix.os.image }}
steps:
- name: Github checkout
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Publish app
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
run: npm run publish

Windows startup events

Add the following code in the main process to prevent Squirrel.Windows launches your app multiple times during the installation/updating/uninstallation.

// main/index.js
if (require('electron-squirrel-startup') === true) app.quit();

Kafka containers with Docker Compose

July 18, 2023

For Compose basics, see the Docker Compose overview. This post covers a single-node Kafka broker in KRaft mode with Kafka UI. Zookeeper is no longer required; it was removed in Kafka 4.0.

Prerequisites

  • Docker Compose installed

Configuration

The following configuration spins up a single-node Kafka broker in KRaft mode with the Kafka UI tool.

The Kafka broker address is localhost:29092, and Kafka UI is available at http://localhost:8085.

# docker-compose.yml
services:
kafka:
image: apache/kafka:4.3.0
hostname: kafka
ports:
- '29092:29092'
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:29093
KAFKA_LISTENERS: CONTROLLER://:29093,PLAINTEXT://:9092,PLAINTEXT_HOST://:29092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
kafka-ui:
image: provectuslabs/kafka-ui:latest
ports:
- 8085:8080
depends_on:
- kafka
environment:
KAFKA_CLUSTERS_0_NAME: local
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092

Run the following command to spin up the containers.

docker compose up

Demo

Docker Compose files for this post live in the kafka-docker-compose folder. Get access via code demos.

Formatting Node.js codebase with Prettier

July 3, 2023

Formatting helps to stay consistent with code style throughout the whole codebase. Include format script in pre-hooks (pre-commit or pre-push). This post covers Prettier setup with JavaScript and TypeScript code.

Start by installing the prettier package as a dev dependency.

npm i prettier -D

Specify rules inside the .prettierrc config file.

{
"singleQuote": true,
"trailingComma": "all"
}

Add format script in the package.json file.

{
"scripts": {
// ...
"format": "prettier --write \"{src,test}/**/*.{js,ts}\""
}
}

Notes

If you use Eslint, install the eslint-config-prettier package as a dev dependency and update the Eslint configuration to use the Prettier config.

// eslint.config.js
// ...
import eslintConfigPrettier from 'eslint-config-prettier';
export default [
// ...
eslintConfigPrettier
];

Using Visual Studio Code, you can install a prettier-vscode extension and activate formatting when file changes are saved.

Demo

Sample codebase for this post lives in the formatting-prettier-demo folder. Get access via code demos.