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.tsTo 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.targetsystemctl 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 secondsonClose 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 numberThe 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.