inkan

Running it

Listen, shut down, workers, the frontend next to it, Bun, Deno, serverless.

app.listen() takes the port from its argument, then $PORT, then 3000, so nothing about where it runs is written into the app. On a server, plainly:

PORT=3000 NODE_ENV=production node src/app.ts

To keep it running and have it start with the machine, a systemd unit:

# /etc/systemd/system/api.service
[Unit]
Description=api
After=network.target

[Service]
WorkingDirectory=/srv/api
ExecStart=/usr/bin/node src/app.ts
Environment=PORT=3000 NODE_ENV=production
Restart=on-failure

[Install]
WantedBy=multi-user.target

systemctl enable --now api starts it, and systemctl stop api lets the open requests finish. With pm2 it is pm2 start node --name api -- src/app.ts, then pm2 save. In a container:

FROM node:24-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production PORT=3000
EXPOSE 3000
CMD ["node", "src/app.ts"]

A PaaS or warden (warden run -- node src/app.ts) works the same way: they set $PORT and stop it with SIGTERM, and that is all inkan needs.

On SIGINT or SIGTERM it lets open requests finish (up to ten seconds) before it exits. app.listener is a plain (req, res) function for your own http.createServer.

Hooks open and close what the app needs around that:

.(() => db.connect()); // awaited before listen() resolves; a throw rejects it
.(() => db.end());      // awaited after the last request, within the ten seconds

onClose runs on SIGINT or SIGTERM while gracefulShutdown is on.

A Node process runs JavaScript on one core. To use all of them:

({ : "auto" }) // one process per core, or a number

The first process starts the workers and only looks after them: they share the port, a worker that dies is replaced (and one that keeps dying stops the whole thing instead of looping), and on SIGINT or SIGTERM every worker finishes its open requests and runs its own onClose. listen() returns in the workers, not in the first process. Log lines and the inspector say which worker answered. Each worker has its own memory: a rate limit or a cache in memory counts per worker.

({
  : "Tea Shop", : "1.0.0", : "…", : [{ : "https://api.example.com" }],
  : "/docs",                  // or false
  : "/openapi.json",       // or false
  : "/_inkan",           // default: on in development, always loopback only
  : true,        // default: on in development
  : 1024 * 1024,
  : "pretty",                  // "pretty", "json" or false; default: pretty in development, json in production
  : () => pino.info(), // takes every request log entry instead of the console
  : "x-request-id",      // or false
  : true,
  : .. !== "production",
  : (, ) => report(, ?.), // ctx is undefined for an error outside any request
});

Every request has an id: the one in x-request-id when it looks safe, a fresh one otherwise. It is ctx.id in the handler, goes back out as a header, sits in every log line and in every problem document as requestId, so a bug report points at the right line in the logs. A fresh id is a random prefix made when the process starts, the worker in a cluster, and a counter: 0k3f9a2w3-1c8. Unique per process and per worker, and cheap, but not a UUID and not unguessable; nothing should rest on keeping it secret. ctx.ip is the client's address, from the socket or the platform.

The frontend next to it

An app that serves its own built frontend registers two plugins: the files, with the fallback a single-page app needs, and compression for everything that pays.

import { ,  } from "@vxnsin/inkan";


  .(()) // brotli or gzip, as the client takes it; streams too, event streams not
  .(({ : "client/dist", : true, : ["/api"] }));

Every route of the app wins against the files. A hashed asset under /assets/ is cached for a year, everything else and the page itself are asked for again (no-cache), with an ETag and a 304. A path without a file gets index.html, except under exclude: a mistyped API call is a 404 problem, not a page. Nothing outside the folder and no dotfile is served.

Bun, Deno and serverless

app.fetch(request) is the app as a web-standard handler: a Request in, a Response out, with the same contracts, hooks, problems and streams.

// Bun
Bun.serve({ : 3000, : (, ) => .(, { : .requestIP()?.address }) });

// Deno
Deno.serve({ : 3000 }, (, ) => .(, { : .remoteAddr.hostname }));

// a platform that calls a fetch handler: Vercel, Netlify, …
export default { : (: Request) => .() };

remote is the client's address where the platform knows it. The inspector only answers a loopback address, so without one it stays shut; the docs page is for everybody. The body limit holds while the body is read, with or without a content-length, and a stream the client stops reading stops its source. CI runs the same requests on Bun and Deno on every push.

Next.js, Vite, TanStack Query and friends

inkanjs/integrations has a package for each: @inkanjs/next turns an app into Next.js route handlers in one line, @inkanjs/vite runs the API inside vite dev on the frontend's port (Vue, React, Svelte, Solid), and @inkanjs/query gives TanStack Query the typed client. Nuxt, SvelteKit, Astro and Remix need no package; the recipes show the few lines.

Other servers

app.exchange({ method, url, headers, body, remote }) is the one door inkan has for any server: plain values in, { status, headers, body | stream } out. listen and fetch use nothing else, and neither does an adapter. The first one is @inkanjs/uws, for uWebSockets.js:

import {  } from "@inkanjs/uws";
const  = await (, { : 3000 });

It is installed apart, so inkan itself keeps no dependencies, and the same set of requests runs against listen and fetch here and against every adapter in inkanjs/integrations. An adapter reads the body up to app.options.bodyLimit, writes the answer, aborts a stream whose client left, calls done() when the answer is out, and runs app.started() and app.stopped() around its server for the onListen and onClose hooks.

On this page