Repository navigation
Releases: inkanjs/inkan
Release list
v0.8.1
v0.8.0
New
Store, one storage interface for everything that keeps state:get,set,delete,
add(set if absent),take(get and delete),increment(count, keeping the first TTL),
and optionallypublish/subscribeandshared. TTLs are in milliseconds, values are
JSON,add/take/incrementare atomic across processes; the JSDoc onStoresays what
an adapter must guarantee. One adapter (Redis, a database, taking the user's own client)
works for jobs,rateLimitand the middleware packages alike.memoryStore({ max, clock })is the generic in-memoryStore: LRU-bounded (default
10000 keys), TTLs,add/take/incrementatomic in the process, publish/subscribe in
the process,shared: false.storeConformance(make, { wait, close, timeout })from the new
@vxnsin/inkan/testingentry registers the node:test cases everyStoremust pass: get,
set and delete, JSON round trips, TTLs,add/take/incrementunder 50 concurrent
calls, look-alike keys kept apart, separate stores frommake, and publish/subscribe when
present.waitcan move a store's own clock instead of waiting in real time.rateLimit({ store, prefix })counts in aStorethe processes share, with one atomic
incrementper request in fixed windows. Without a store it counts in memory as before.- A frozen API. From 0.8.0, what is marked stable is not removed or changed in meaning in
a minor release; a rename ships as a deprecated alias for at least two minors and six
months first, with a one-time warning in development. Experimental parts (jobs,
WebSockets, seals,check, a few hooks) are marked@experimentaland may change, named
here when they do. The policy and both lists are indocs/STABILITY.md; the public types
are committed inapi/inkan.d.ts.txt, and a test fails when they change unnoticed
(npm run api:update). scope.around((ctx, next) => …)wraps the handler of every route of a scope and the
scopes inside it, those defined before the call too, where route middleware runs.- Every hook takes
{ order: "last" }: after the other hooks of its kind on the route. register(plugin, options, { prefix }): where a plugin is mounted, apart from what it
is handed.ws: { origins, maxMessage, heartbeat, handshakeTimeout }on the app sets the defaults
of every WebSocket route. OneOriginstype serves WebSockets andcors({ origins }):
"*", a list, or(origin, ctx) => boolean, wherectxis aPreContext(method,
path, headers, protocol, ip).corsis a shared plugin:app.register(cors(…)), orscope.register(cors(…))for
the routes of one scope.problem(status, type, { detail, extra, headers }); the positional form stays.stateKey<T>(name): a typed slot inctx.state,key.get(ctx)andkey.set(ctx, v),
under a symbol no string key reaches.ctx.httpVersion,app.all(path, spec, handler)(GET, POST, PUT, PATCH and DELETE),
joinPathfor plugins, andapp.logRequest(entry)for adapters that answer
some requests themselves.AdapterRequesttakes anhttpVersion.listen({ port, host, signal }): aborting the signal stops the server and the app as a
SIGTERM would, without ending the process.listen(port, host)stays.- A warning when development mode was guessed (no
devoption, NODE_ENV is not
"production") andlistenbinds an address other machines reach: most likely a deploy
without NODE_ENV=production.dev: trueor a loopback host quiets it. currentContext(),inject<T>()typing the body, asignalfor the typed client's
calls, andformatReport/formatDifftaking{ title, color }.ctx.logandctx.startedAtare reserved for inkan: a decoration of either name still
works, with a warning in development.
Faster
- An async handler's answer is written where its promise settles, in the one
thenon
it, as Fastify does: no async frame, no second promise. On the runner the async handler is
now level with plain node:http (100 %, from 98 %). - As fast as 0.7.0 everywhere else: the CPU per request is the same, scenario by scenario.
On the runner inkan scores 91.0 against Fastify's 89.9 (median of three runs), level to
slightly ahead. The numbers are inbench/reports/0.8.0.md.
Changed
app.job({ store })takes aStore. Jobs keep one key per job, forgotten by the store's
TTLretainmilliseconds after the job ends; on asharedstore, progress and cancels reach
the other processes throughpublish/subscribe(a shared store without them is polled
once a second while someone follows a job). Writes of one job are coalesced, latest wins.memoryStore()returns the genericStoreinstead of the jobs-only store of 0.7. Passing
it toapp.jobworks as before; withmax, the key used longest ago goes first, not the
oldest finished job.JobRecordkeepscreatedAt,startedAtandfinishedAtas epoch milliseconds, since
records go through JSON. The job routes still answer with dates.- Milliseconds everywhere, except where an option mirrors a header in seconds (
maxAge,
retryAfter): a job'sretainand a route'scache: { ttl }are milliseconds. app.routes()hands outreadonly OperationRoute[]: each route as written (method, path,
security, meta, spec), frozen. The app's own records are not API any more.app.optionsisReadonly<AppOptions>: read it, it is fixed once the app is made.onError(error, ctx)is typedctx: Context | undefined, as it always was at runtime:
an error from outside any request (the socket, a stream after its answer started) gets
undefined, where before it got a stand-in object.- The public types hold no internals any more: no
_-prefixed members, noRouteRecord
(no longer exported),app.handleis private, and the helpers inside modules are out of
the.d.tsfiles. Code that reached for them was reaching past the API. inkan create plugindeclaresinkan: ">=0.8.0 <1.0.0": from the minor that runs up to
the next major, as the stability policy allows.app.use(cors())still works and is deprecated; preflights to a path without an OPTIONS
route are answered by the plugin as they were by the middleware.
Deprecated
Each still works, says so once in development, and is listed with its replacement in
docs/STABILITY.md.
scope.describe(fn)→scope.onOperation(fn).decorateRequest(…, { before: "handler" })→{ resolve: "preHandler" }.onSend(fn, { last: true })→onSend(fn, { order: "last" }).register(plugin, { prefix })→register(plugin, options, { prefix }); a plugin that reads
options.prefixstill finds it there, and should readscope.prefix.app.job(path, { keep })in seconds →{ retain }in milliseconds;{ every }→{ schedule }.cache: { seconds }→cache: { ttl }in milliseconds.- The
wsOriginsoption →ws: { origins }; theWsOriginstype →Origins. cors({ origin })→cors({ origins });app.use(cors())→app.register(cors()).context()→currentContext().buildOpenAPI(routes)→app.openapi().JobStore→Store.
Fixed
- Security: the request inspector (
/_inkan, development only) answered requests that came
throughapp.exchange()without aremoteaddress, because a missing address counted as
loopback. An adapter that leftremoteout exposed the inspector, and the request log in it,
to every client. A request without an address is now never served the inspector;inject
still is, since it comes from inside the process. - Tests that write modules into a temporary folder mark it as ES modules, so they pass on a
machine with apackage.jsonof"type": "commonjs"above the temp folder.
v0.7.0
New
- Background jobs.
app.job(path, { body, progress, result, concurrency, queue, keep, timeout, owner }, run)
defines five ordinary routes around a queue, so the docs, OpenAPI, hooks, security, the
typed client andinkan checksee them like any other:POST pathstarts a job (202,
with itslocation; 503 when the queue is full or the server stops),GET path/:idtells
how it stands,GET path/:id/eventsfollows it as server-sent events (its status, the
latest progress only, then the end, and the stream closes),GET path/:id/result?wait=10
waits for what it made, andDELETE path/:idcancels it or forgets it once it is over.
The work getsjob.input,job.signalandjob.progress(p); ids are random, a job of
someone else is a 404 withowner, a thrown problem is the job'serror, and in
development a progress or result that breaks its contract fails the job. Finished jobs are
kept forkeepseconds. On SIGINT or SIGTERM waiting jobs are canceled, event streams
end at once, and running jobs get a few seconds before their signal is aborted.
Jobs run in the process that started them: one still running when it ends is lost.
Withworkers,listenrefuses jobs kept in memory;JobStoreis the interface for a
store the processes share,memoryStore()the one inkan has. The routes get examples made
from the first start example, soinkan checkcovers them. - WebSockets.
app.ws(path, { params, query, headers, message, send, security, origins, maxMessage, maxConnections, heartbeat, handshakeTimeout }, (socket, ctx) => …),
RFC 6455 written onnode:http's upgrade event, no dependency. The upgrade request is
routed and goes throughonRequest, the security, the params/query/headers checks,
middleware andpreHandlerlike a GET; a no answers with its status (400, 401, 404, …)
and no socket.messageis what the client sends: JSON, checked, typed in
socket.on("message")andfor await (const m of socket); one that is not JSON or breaks
the contract closes the socket with 1007 and the reason.sendis what the server sends:
socket.send(v)writes it as JSON with only what the contract lists, and in development a
value that breaks it throws (reported, 1011). Withoutmessage, text arrives as a string
and binary as a Buffer. Frames: masking required (1002), fragments, UTF-8 checked (1007),
maxMessage(default 1 MiB, 1009), ping/pong with aheartbeat(default 30 s) that cuts
off a peer that stops answering, the closing handshake with its codes.socket.send
resolves once the socket takes more;bufferedAmountsays how much waits. Messages that
come before the handler listens wait for it.ctx.signalaborts when the socket closes.
originsguards against cross-site WebSocket hijacking: an upgrade whose Origin is not
the app's own (Host andctx.protocol) is a 403origin-not-allowedbefore any hook runs;
a list adds origins, a function(origin, ctx) => booleandecides alone,"*"lets every
page in, a request without an Origin (not a browser) goes through;wsOriginssets it for
the whole app. Also before any hook: an upgrade that is not a GET over HTTP/1.1 without a
body is a 400,sec-websocket-keyhas to be canonical base64, and pastmaxConnections
it is a 503too-many-connections. A handshake that is not answered within
handshakeTimeout(default 10 s) is cut off. Limits: a message comes in at most 1024
frames, each after the first counted 64 bytes againstmaxMessage; text is checked as
UTF-8 frame by frame; at most 4 ×maxMessage(1 MiB at least) waits to be written, past
it the socket is cut off with 1008; a peer that floods pings without reading gets only
its latest one answered, and nothing goes out after the server's close frame. Close
reasons name the path and the rule, never the value sent. Binary messages are copies of
their own.
On SIGINT or SIGTERM every socket is closed with 1001. A plain request to a WebSocket
path is a 426.listenonly listens for upgrades when the app has a WebSocket route;
a hand-made server takesserver.on("upgrade", app.upgradeListener). Not there:
permessage-deflate (no extension is negotiated), subprotocol choice (a hook may set
sec-websocket-protocol), andapp.fetch(Bun and Deno have upgrade APIs of their own).
Withworkers, each process holds its own sockets: broadcast with aSetof sockets per
process, or through a shared bus. client.ws(path, { params, query })opens a WebSocket route with the platform's
WebSocket:sendtakes what the route'smessagesays, messages arrive parsed and
narrowed by the route'ssend,readyresolves once it is open, and leaving a
for awaitloop closes it. In Node,headersgo with the upgrade.- OpenAPI and the docs list WebSockets: a GET with a 101 and an
x-inkan-websocket
extension holding themessageandsendschemas (on the path item where the path has
a GET of its own); the docs page shows them asWSwith both contracts. client.events(path, { params })reads a route that answers witht.events(...)as an
async iterator, each event narrowed by its name; leaving the loop closes the stream.- Cookies in
inkan check. The examples of oneafterchain share a cookie jar, as a
browser would: a cookie an answer sets goes with the requests after it that itsPath
covers, until an answer clears it (Max-Age=0or anExpiresgone by). Every chain
starts with an empty jar, so a login example and a/meexample after it pass, and
nothing crosses into another chain.keepreadscookies.<name>(a cookie the answer
sets) besidesbody.<path>,headers.<name>andstatus. - Per-request decorations.
scope.decorateRequest("user", (ctx) => …)puts a value on
the context that is made the first time a request reads it and kept for the rest of that
request; a request that never asks never makes it. Typed likedecorate, refused for the
same names, and only the scopes that use it carry its getter. - Async per-request decorations. An
initthat returns a promise makesctx.userthat
promise, typedPromise<User>: made once however often it is read, never for a request
that does not read it, and a problem it throws is the answer. With
decorateRequest(name, init, { before: "handler", when: (route) => route.security.length > 0 })
it is awaited after the input is checked and before the preHandler hooks, so they and the
handler see the value itself, typed without the promise.whenpicks the routes, asked
once per route; reading it on a route it leaves out throws, instead of handing over a
promise typed as the value. Apps that do not use it pay nothing for it. - Types out of shared plugins. A plugin made with
shared: truethat returns its scope
((app) => app.decorateRequest("user", …)) hands its decorations on: after
app.register(auth)the handlers seectx.usertyped.Pluginhas a third type
parameter for what it adds. - Routes out of plugins reach the typed client. A plugin made with
plugin(), shared or
not, that returns its scope hands on the routes it defined in that chain: after
app.register(auth, { prefix: "/auth" }),client<typeof app>knowsPOST /auth/login
with its params, body and answers. Prefixes add up through plugins that register others.
Pluginhas a fourth type parameter for its routes; a plain function passed to
registerhands no types on. ctx.route.securityandctx.route.meta. Hooks see the credentials a route asks
for, its own or its group's, and a free-formmetafrom its spec
({ meta: { auth: { roles: ["admin"] } } }). Plugins name what they read by extending
the exportedRouteMetainterface. Worked out once per route, before the first request.scope.describe((operation, route, components) => …)lets a plugin add to the
OpenAPI operation of every route in its scope: header parameters, answers, descriptions,
and details of a security scheme such asbearerFormat: "JWT".trustProxyon the app:true, a number of hops, or a function that names your
proxies.ctx.ip, and with itrateLimit, then readsx-forwarded-fororforwarded.
Off by default; an app without it pays nothing for it.onSend(fn, { last: true })runs a hook after every other onSend hook of a route.
compressuses it, so a hook that reads the body (an ETag) sees it before it is packed,
wherever either was registered.ctx.rawHeaders: the request's headers as they arrived, names in lower case, even
where a route's header schema cutctx.headersdown to its contract. The request's own
object, not a copy: read it, do not change it.ctx.secureandctx.protocol("http"or"https"): the TLS socket under
listen, the URL's scheme underapp.fetch,secureon an adapter's request, and with
trustProxywhat the trusted proxies forward inx-forwarded-protoor forwarded's
proto=.ctx.urltakes its scheme from it.ref(schema, name?)fordescribehooks, their fourth argument: lists at.*
schema once undercomponents.schemasand returns{ $ref }to it, as a route's named
schemas are. An unnamed schema needsname.- The route a
describehook gets is public: the frozenctx.routeview (method,
path,security,meta) plus thespecit was written with, typedOperationRoute;
no internal fields of the route record. scope.devandscope.prefix, read-only: whether the app runs in development, and
the path prefix of the scope a plugin was given (""for the app).- Plugins name the inkan versions they work with.
plugin(fn, { name: "inkan-quota", inkan: ">=0.7.0 <0.8.0" }):register()throws on any other
(inkan-quota needs inkan >=0.7.0 <0.8.0, this app runs 0.9.1), and a range that is not
one throws ...
v0.6.0
New
-
More than JSON, from the context.
ctx.text(body),ctx.html(markup),
ctx.redirect(to, status)(301, 302, 303, 307 or 308) andctx.notFound(detail), the
same 404 problem an unknown route gets, throughonProblemlike any other. Each can be
taken apart likestatus(({ html }) => …) and costs nothing until used. -
The
htmltag, which escapes every value put into it, joins lists and writes nothing
fornull,undefinedandfalse. A handler can returnhtml`…`as it is.
raw(markup)lets markup you made yourself through. -
Layouts.
app.layout((content, props, ctx) => …)andctx.render(content, props):
pages in the look of their scope; a plugin can have its own, and the closest wins. -
Cookies.
ctx.cookiesreads them,ctx.setCookie(name, value, options)sets one,
HttpOnly and SameSite=Lax unless told otherwise, andctx.clearCookie(name)lets one
expire. Every cookie goes out as a Set-Cookie of its own, over node:http,fetchand
inject(res.cookies). -
Rows as they come. A handler can return a generator of rows for a list in its
contract, such as one over a database cursor: the rows go out as they are read, as a JSON
array or as NDJSON foraccept: application/x-ndjson, each written to the contract and
checked in development. -
ctx.csv(rows, options)writes rows as CSV the same way, with a separator, a BOM,
a file name, and formulas defused unless told otherwise. -
ctx.signalaborts when the client goes away or the route's time is up;timeout
on a route or the app answers 504 past it. -
cacheon a route keeps the handler's answers per input and caller for a while, and
asks once for many requests that come in at the same time.
Faster
- Less work per request. A request id from a counter instead of a random UUID, route
data worked out once when the app is built, a request with a body routed once, no context
for a request no route takes, and the answer written into one object. - Schemas check through stamps: one function per schema, made once from its rules, in
place of a walk over the schema tree on every request. They check exactly as before. - Answers trimmed to the contract in one step. A plain object that holds just what the
contract lists goes to the native writer as it is; anything else is copied with only the
declared fields first. The copy is faster than the writer it replaces. - Middleware without async frames of its own, and bodies read without a promise.
- The numbers, phase by phase, are in
bench/reports/0.6.0.md.
Fixed
- An answer can no longer carry a key its contract does not list through an object whose
declared field came from its prototype or was not enumerable; such objects are copied
with only the declared fields first. - A declared field named like an
Object.prototypemember (constructor,toString) is
left out when missing, instead of being written asnull. - An invalid
Datein at.date()field is written asnullinstead of throwing. - Functions and symbols in object fields and records are left out, as
JSON.stringify
leaves them out, instead of being written asnull. - An event name like
constructoris a contract error fort.events(), not a crash.
Changed
- The request id is no longer a UUID. A fresh id is a random prefix made once per
process, the cluster worker, and a counter:0k3f9a2w3-1c8. It is unique per process
and worker, but not unguessable; an id a client sends is still used when it looks safe. - Seals are format 2.
inkan sealnow stamps the checks only, and writing stays the
schema's own, so a sealed contract can never write differently. A seal made by 0.5 is
not used (with a warning) untilinkan sealruns again. - An answer that had to be trimmed is written in the order of its contract's fields; one
that already fits keeps its own order, as before.
For adapters
AdapterResponse.cookiesholds the Set-Cookie headers, one per cookie; an adapter writes
each as a header of its own.
0.5.0
New
- Bodies as they come.
t.binary()takes the whole body as bytes, of any media type,
for a file sent as the body itself.t.stream()hands the body to the handler unread, chunk
by chunk as it arrives, for uploads larger than memory; past its limit the stream throws a
413 problem, and an answer before the body is read (a 401, a problem the handler threw)
still reaches the client. Both take.max(bytes)and.accept(...types); anything else
is a 415. OpenAPI lists the media types, and the typed client sends aBlob, bytes or a
ReadableStreamas they are. - A body limit per route.
bodyLimiton a route, or themaxof at.binary()or
t.stream()body, instead of the app's. The limit is known before the body is read, so a
request that says it is too large is a 413 at once. For adapters,app.bodyFor(method, url)names the limit and whether to hand the body over unread, asstreamon
app.exchange();app.fetch()andapp.exchange()keep to it. serveStatic({ dir, spa, exclude }), a plugin for the built frontend next to the API:
hashed assets cached for a year, everything else asked for again, ETag and 304, HEAD, and
withspatheindex.htmlfor every path without a file, except underexclude. Every
route of the app wins against the files; nothing outside the folder and no dotfile is
served.compress(), a plugin on one onSend hook: brotli or gzip, as the client's
accept-encoding takes it, for text, JSON, JavaScript and SVG above a threshold, streams
too, event streams not.injectunpacks what it gets, asfetchdoes.ctx.ip, the client's address, from the socket or from the platform (app.fetch(req, { remote }), an adapter'sremote).rateLimitcounts by it, so clients on Bun, Deno or
an adapter are told apart too.- File routes under Vite. The import that loads them is marked
@vite-ignore, so a
bundler that sees inkan as source no longer warns about it.
Changed
- @inkanjs/uws moved to inkanjs/integrations,
next to@inkanjs/next,@inkanjs/viteand@inkanjs/query, and is on npm. The bench uses
it from there. - Node 22 or newer. The package said 20, which reached its end of life in April 2026
and was never in CI; CI tests 22, 24 and 26.
0.4.0
New
- Hooks.
onRequest(a route was found, the body is not read yet),preHandler(the
input is checked and typed),onSend(every answer, problems too, and it may change it),
onResponse(after the answer is written) andonProblem(every problem, before it is
written, and it may change it or hand back another).onRequestandpreHandlermay
answer by returning a value. Every route gets the hooks of its scope chain joined once,
before the first request; a route with none takes the same path as before.
(#23) - Plugins.
app.register(plugin, { prefix, ...options })runs a plugin with a scope of its
own: its routes, hooks and decorations stay there, under its prefix.plugin(fn, { shared: true })makes one that adds to the scope it is registered in instead. Plugins load in
order, an async one holds back the ones after it,await app.ready()waits for all of them,
and one that fails stopslisten().inject,checkand the CLI wait too. Their routes
are in OpenAPI, on the docs page and ininkan checklike any other.
(#23) - Decorators.
app.decorate("db", pool)puts a value on every context in its scope,
typed: the handler'sctx.dbhas the type ofpool. It lives on the context's prototype,
so it costs nothing per request. A name the context already has, by itself or from another
decoration, is refused. (#23) rateLimit({ max, window, key }), a plugin built on nothing but these APIs: a 429
problem withretry-afteraftermaxrequests per client, andx-ratelimit-remainingon
every answer. Registered in a plugin, it limits only that plugin's routes.inkan seal: contracts as plain code.npx inkan seal src/app.tswrites every contract
out as code of its own intoinkan.seal.js(with a.d.ts), andinkan({ seal })runs on
it. The engine can tune code that only ever sees one shape: checking a body of 50 objects
and writing an answer of 100 both take about half the time. A sealed contract does exactly
what its schema does: the same values, the same messages and paths, and it keeps back every
key the contract does not list. No eval and nonew Function: what is in the file is what
runs. At start the app writes the code again from the live contract and uses an entry only
while the hashes match, so a contract that changed since runs unsealed and inkan says
which; a seal of another format is not used at all. Equal contracts share one entry.
refine,transform, unions, records, dates and lazy schemas run unsealed for now.
(#18)app.fetch(request): the app as a web-standard handler, aRequestin and aResponse
out, for Bun, Deno and serverless platforms, with the same contracts, hooks, problems and
streams. The body limit holds while the body is read, with or without acontent-length;
a stream the client stops reading stops its source; it waits for plugins likelisten
does.{ remote }passes the client's address where the platform knows it: the inspector
answers only a loopback address, so without one it stays shut. CI runs the same requests
on Bun and Deno. (#14)- Security schemes.
security: "bearer" | "basic" | { apiKey, in }on a route, a list for
alternatives,.security(…)for a group, a plugin or the whole app, andsecurity: false
to open one route again. A request without the credentials is a 401 problem with
www-authenticate, before its input is looked at; whether they are good stays the job of a
hook or middleware. OpenAPI getscomponents.securitySchemes,securityper operation
and an implied 401; the docs page shows a lock and its token field starts on the right
header. (#12) - Response headers in the contract.
responseHeaders: { 201: { location: t.string() } }
goes into OpenAPI and onto the docs page, and in development an answer without a promised
header (or with one of the wrong shape) breaks the contract like a wrong body does.
(#12) - Routes from the file tree.
app.load(new URL("./routes", import.meta.url))defines a
route for every file in a folder:teas/[id].tsexportingGETandDELETEis
/teas/:id,indexis its folder,[...rest]takes the rest.route(spec, handler)types
the handler from the contract asapp.getdoes. Nothing happens at import time;load
runs in order with the plugins,listenwaits for it, and in a plugin the routes go under
its prefix. Files starting with_,.d.tsand test files are left alone. A file that
exports no route says so by name. (#6) - Every core:
workers.inkan({ workers: "auto" })(or a number) runs one process per
core on one port withnode:cluster. The first process only looks after them: it replaces
a worker that dies, stops instead of looping when they keep dying, and on SIGINT or SIGTERM
asks every worker to finish its open requests and run itsonClose. Log lines, the
listeningline and the inspector say which worker answered. The bench has an
inkan-clusterentry next to the one-core ones. (#24) - Adapters:
app.exchange(), the one door for any server: a request in plain values, the
answer in plain values, plusapp.started()andapp.stopped()for the onListen and
onClose hooks.listenandfetchgo through it too. The first adapter,
@inkanjs/uws, runs an app on uWebSockets.js; it is installed apart, with
uWebSockets.js as a peer you add on purpose (it comes from GitHub, which npm 12 only
allows when asked), so inkan keeps no dependencies. One set of requests runs in CI against
listen,fetchand every adapter, and the bench has aninkan-uwsentry.
(#25) inkan learn, a tutorial in the terminal.npx @vxnsin/inkan learnsets up a practice
app and gives twelve quests, one idea each: a first route, typed params, rules for a body,
examples as tests, problems, groups, security, hooks, the docs page (it sees you press
send there), changes that break clients, the seal and plugins. Every save is checked
throughinjectand answered with what is still missing;hgives a hint and then a
clearer one,sskips,osaves the OpenAPI document,wwrites the seal. The app runs
on a port meanwhile, for/docsand/_inkan. inkan is linked into the practice folder
instead of installed, so it works at once and offline, and the progress is kept. Every
quest has a test that solves it, so CI keeps the tutorial working.
(#26)- How fast, in the README. Twelve scenarios next to node:http, Fastify, Hono and Express,
every answer checked before it is measured. The bench now runs every server at once and
measures each scenario for all of them back to back, in a turning order, so a runner that
slows down over the hour slows everybody alike. On a 4-core GitHub runner inkan scores
85.4 against bare node:http, next to Hono (85.0) and Fastify (88.1), and 133.5 on
uWebSockets.js. (#18) bench/cpu.mjs: the CPU time one request costs the server, next to the others, which
does not depend on how fast the load generator is. The bench workflow runs it with
kind: cpu(orboth).
Fixed
- Keys the contract does not list no longer leave the server in production. The README
promised that apasswordHashon a returned row never goes out, but only development
dropped such keys: withNODE_ENV=productionthe answer went out as the handler returned
it. Every answer with a schema is now written by a writer built from that schema, in every
mode, and it writes only what the schema lists, at every depth: objects, arrays, records,
discriminated unions and recursive shapes..passthrough()still keeps everything, and a
.transform()writes what it made. Affects every release up to 0.3.0; update. - A path that starts with
//is routed as that path. The request target was read with
new URL(), which takes//elsewhere/xfor a host and a path, so such a request was routed
as/x. inkan now splits the target itself;ctx.urlis still aURL, built from the
Hostheader when a handler asks for it. - The docs page and the inspector escape everything they render, including operation ids,
methods, statuses and the links from the app's options, and they only send a request (with
its token) when it stays on the same origin. A replay of a recorded//elsewhere/xis
refused instead of going to another host. Found by Socket's code analysis. - Answers have a
content-lengthagain.writeHeadfixed the headers before the body was
known, so Node sent every answer with chunked encoding: extra framing on each response and
a higher tail latency under load. A whole body now goes out with its length; streams stay
chunked, and aHEADtells the length theGEThas.
Changed
- Answers with a schema are written by that schema's own writer, built once per schema,
instead of byJSON.stringify. - Faster request path. Static paths are one map lookup instead of a walk, parameters are
collected without a copy per level, anddecodeURIComponentonly runs on paths that need it.
A route without middleware calls its handler directly, a sync handler and a request without a
multipart body cost no extra await, request headers are no l...
0.3.0
New
- More schema power.
t.date()takes ISO date-time strings (orDates) and hands the
handler aDate; answers send it back as an ISO string..refine(fn, message)adds your
own rules,.transform(fn)changes the parsed value,t.discriminated(key, options)picks
one branch by its tag (OpenAPIoneOfwith adiscriminator), andt.lazy(() => schema)
makes recursive shapes possible. Thanks @AndrewCMD!
(#13, #22) - The help text is coloured, and so are logs in GitHub Actions (
NO_COLORstill turns it off). - Request ids. Every request gets one: the
x-request-idit came with when that looks safe,
a fresh UUID otherwise. It isctx.idin the handler, goes back out as a header, and lands in
the log line, the inspector and every problem document asrequestId.requestId: "x-correlation-id"
picks another header,requestId: falseturns it off. (#15) - Structured logs.
log: "json"writes one JSON line per request (time, id, method, path,
route, status, ms, notes), andlogger: (entry) => …hands every entry to your own logger
instead of the console. (#15) - Edit a request on the docs page. Every example has an edit button: params, query,
headers and body, filled in from the example, sent as you changed them. An edited request
says so and never counts towards a route's seal. reset brings the example back, and
curl copies the request as a curl command. (#8) - A token for the docs page. A field at the top puts an
authorizationorx-api-key
header on every request from the page. It lives insessionStorage, so it is gone with
the tab, never lands in a URL, and shows as ••• in a copied curl command.
(#8) - Inspector: replay. Sends a request again and shows the old and the new answer side by
side, with whether they match (arequestIdalone does not count as a change). Secret
headers are not sent again, and the page says so. The replay shows up in the log as
replay of #n. (#7) - Inspector: copy as example. Turns a request into a ready
examples: [...]entry with
its params, query, body and status, plus any custom headers, but never secret headers or
the ones every client sends. Paste it into the route andinkan checkholds it from then on.
(#7) inkan examples. Pulls a complete example project into a folder, explained file by file:
a README per example that says what to read in which order, numbered comments in the code,
something to change first, and a "try this: break it on purpose" list that shows what inkan
catches. Six of them, in reading order:hello,tea-shop,auth,testing,openapi,
deploy.npx @vxnsin/inkan examplesasks which one,--listlists them,
inkan examples tea-shop my-shopgoes straight there. A folder that is not empty needs
--force. The templates ship inside the package, so it works offline and fits the installed
version, and every one of them passesinkan check --strictin CI.
(#1)inkan diff. Compares a saved OpenAPI document with the app, or two documents, and says
what would break a client written against the old one: a route or a status that is gone, a
field or parameter that is required now, a value a request may no longer send, a tighter
limit or pattern, a field an answer may leave out or set to null now. Requests may only get
looser and answers only stricter. Everything else is listed as safe. It exits with1when
something breaks, so CI can ask for a major version;--jsonfor tools. Also as
diffOpenAPI(before, after). (#11)- Examples that build on each other. An example can
keepvalues from its answer
(keep: { id: "body.id" }, alsoheaders.…andstatus), and another one can run
afterit (after: "POST /teas > a new oolong") and use them as{id}in its params,
query, headers and body.beforeEachruns once before the whole chain, the order comes from
afterrather than from the files, and a broken step, anafterthat points nowhere or a
circle each fail with a sentence that says which. The docs page runs the chain when you
press send, and the tea-shop example shows it. (#10) - Streams. A handler may return a Node
Readable, a webReadableStreamor any async
iterable; it goes out piece by piece, with backpressure, and stops when the client leaves.
(#5) - Server-sent events.
sse(async function* (signal) { … })answers with an event stream,
andt.events({ tick: … })puts one schema per event into the contract and into OpenAPI as
text/event-stream. The signal fires when the client goes away, quiet streams get a
keep-alive comment, and in development an event that breaks its schema ends the stream with
anerrorevent.inkan check,app.inject({ events: n })and the docs page read as many
events as an example expects, so endless streams can be examples too.
(#5) - Uploads.
t.file().max(bytes).accept("image/*")in a body makes it
multipart/form-data, parsed by the platform itself, with no dependency. Files arrive as
{ name, type, size, data }, the fields next to them are coerced like a query, and a file
that is too big or of the wrong type is a 400 that says which.fileExample(name, content, type)puts a file into an example;injecttakes aFormDatatoo.
(#5) - A typed client, with no codegen.
client<typeof api>(baseUrl)from
@vxnsin/inkan/clienttakes its types from the app: paths, params, query, body and every
answer per status, with Dates as the strings they arrive as. A wrong path, a missing param
or a body of the wrong shape does not compile. Failures come back as problem documents,
never as thrown errors. It is a few lines aroundfetchwith no server code in it, so it
runs in a browser too;{ fetch }and{ headers }(also as a function, for fresh tokens)
are optional. The routes have to be defined in a chain (inkan().get(…).post(…), also
mount) for the type to see them. (#2)
Changed
toJSONSchema()keeps named schemas in$defsand refers to them, instead of writing them
out inline, so recursive schemas work there too.- In production, requests are now logged as JSON lines by default (before: not at all).
log: falsekeeps it quiet. Development keeps the short coloured line. - Inspector log entries carry
exampleandrequest.clipped. A request whose body was too
long to keep whole is not offered for replay, and its example leaves the body out instead
of guessing.
0.2.0
New
- CORS out of the box.
OPTIONSanswers on its own with anAllowheader, and
app.use(cors({ origin, credentials, maxAge }))handles preflights for one origin,
a list, a function or any. Error answers keep the CORS headers, so a page can read
a 404 instead of hitting a CORS error. (#3) inkan checkshows what no example covers. A status the contract promises but no
example answers with is listed under its route and on the docs page.--strict
(orapp.check({ strict: true })) turns it into a failure, for CI.
(#9)- Lifecycle hooks.
app.onListen()runs beforelisten()resolves, and a throw rejects it.
app.onClose()runs on SIGINT and SIGTERM after the last request, still within
the ten seconds. Thanks @RinZ27!
(#16, #20) - Colour in the terminal. The start banner, the request log,
checkandroutesare
coloured: the seal is red, methods and statuses have their own colours. Off when the
output is not a terminal orNO_COLORis set, on withFORCE_COLOR.
Changed
- The
Allowheader of a 405 now listsOPTIONStoo. - Headers that middleware set stay on error answers.
CheckReporthas a newuncoveredlist.

