Repository navigation
Releases: tetsujs/tetsu
Release list
v0.6.3
Added
@tetsujs/static, a new package:staticFiles()serves the files of a directory fromfallbackor a route, a built site, a single-page app or assets, inside the pipeline, so the application's hooks apply to them. A path is checked before the disk is touched, a directory is redirected to its trailing slash, a file hasETagandLast-Modifiedwith304, and Bun answersRange.notFoundsends a page of the site to a browser,spathe app's shell, andprecompressedthe.bror.gzcopy beside a file. The route stays out of the OpenAPI document unless it saysdocs: { hidden: false }(#74).@tetsujs/core: a status of a response map can name the media type of a body that is not JSON —{ 200: { contentType: "text/csv", body } }— for a status the handler answers with aResponseit builds: a file, a CSV export,sse(). Returning a value for it, or nothing, is a compile error, since a value would leave as JSON and nothing as an empty body; a type the compiler knows only as astringcounts as not JSON, and where it does not see the key at all, it is a500when responses are validated.route()refuses a content type that is not a bare type or range, such as one with a charset.@tetsujs/openapi: such a status is documented under its own media type, with itsbodyas the schema, instead ofapplication/json. So is a responsedocumented()gives a hook, with acontentTypeof its own;documented()refuses one that is not a bare type or range.@tetsujs/openapi:documented()annotates a handler as it does a hook, so a package's handler describes every route that mounts it. Its responses are the route's own, andhidden: truekeeps the route out of the document unless the route saysdocs: { hidden: false }.@tetsujs/openapi:assertDescribedtakes a body under a range of media types, such asimage/*or*/*, and compares types without regard to case. It parses a body as JSON only underapplication/jsonor a+jsontype, and takes an empty body under any other.
Fixed
@tetsujs/core: a route with aqueryschema answers a request without a usableHost, such as an HTTP/1.0 health check, instead of failing it with a500. Bun leavesreq.urlrelative then, and the query is read against a placeholder origin. AHostsuch as[makesreq.urlno URL at all, and such a request has no query.@tetsujs/request-log: for the same requests,arrivalLog()no longer turns the request into a500, andaccessLog()writes its record instead of reporting a failure. The path is read from a relativereq.urltoo, and areq.urlthat is no URL is written as it came, up to its query.@tetsujs/core: a response map keyed by anything but a status, a range such as4XXordefault, or with an entry that is neither a schema,nullnor an object of parts, is refused where the route is declared. It passed: a key such as600matched no response and was documented as one, an entry such as a bare string made every validated response of its status a500, anundefinedentry went unchecked, andopenapi()threw aTypeErroron either of the last two.defaultand the ranges, OpenAPI's own keys, are taken as before: they are documented, and declare no status a response may leave with.@tetsujs/openapi: a response a hook documents below400without aschemahas no body in the document — a redirect, or the refusal of asecured()hook that sends to a sign-in page. It was described as the error envelope.@tetsujs/openapi: the responses hooks document are no longer merged by status and description. Two guards that answer403 Forbiddenwith codes of their own, such asNOT_OWNERandPLAN_LIMIT, are both documented, where the second was dropped; a description that a coded response already gives is said once; and two definitions of one code with differentfieldsgive a warning, where one was dropped without a word.@tetsujs/openapi:defaultand a range such as4XXin a response map are described as what they are,Any other responseandClient error, not asHTTP NaN.
Full Changelog: v0.6.2...v0.6.3
v0.6.2
Added
@tetsujs/core:serve(app, { stop: false })leaves the server running for the caller, andrequest.stop()stops it. A server started inbeforeAllneeds both: Bun runs anafterAllregistered inside a hook as soon as the hook returns, so the oneserve()registers stopped it before the first test (#72).- Every package's README links its page on the site, the same page as Markdown, and
llms-full.txt, the whole documentation in one file, and says that the type definitions document every option. The core's pointed atllms.txt, which is only the index (#75).
Fixed
@tetsujs/sse: a stream with a keep-alive — everysse()by default, astream()given one — sets its request's idle timeout to the interval and ten seconds more once it is read (#71). Bun closes a connection that sends nothing for 10 seconds by default, and a quiet feed was cut before its first heartbeat at 15. The stream's timeout replaces the server's even where that one is longer, or0, and one the handler set itself, such as theserver.timeout(req, 0)of Bun's own guide; a generator that wants another sets its own. A quiet feed now stays open, so one withoutuntilholds a stopping server for its whole grace period: end it ondraining. On a unix socket, where Bun ignores a request's timeout, the heartbeat has to stay under 8 seconds.@tetsujs/sse: aheartbeatMs, or a keep-alive'severyMs, that isNaN,Infinity, a negative, over 2³¹ − 1 or not a number at all is refused with aTypeErrorwhere the stream is made. Read as none, aNaNheartbeat — whatNumber()of an unset variable gives — let Bun close every quiet feed, andInfinitymade a timer beat every millisecond. A string, read from JSON say, used to pass as the number it spelled.@tetsujs/core: a serverserve()started says what stopped it — its ownafterAll,request.stop()orstopServers()— when a request, a client orrequest.urlreaches it afterwards. One started inbeforeAllfailed every request with a bareConnectionRefused.@tetsujs/core: intestCtx(),ctx.server.timeout()does nothing instead of throwing, since a unit test has no connection to time out. A handler that sets a timeout, or returns a stream with a keep-alive, can be called and read directly.@tetsujs/lifecycle,@tetsujs/sse: the examples ofstoppinganddrainingare written in the order an application can be: the route, then the server, thenonShutdownSignals(server), with the handler reading the signal when a request comes in. They took the signal from a server the routes had to exist before (#73).@tetsujs/sse:sse()sendsx-accel-buffering: no. nginx, which buffers a proxied response by default, held the events until its buffer filled or the stream ended, so a live feed behind it looked dead while the server was sending (#79).
Full Changelog: v0.6.1...v0.6.2
v0.6.1
Fixed
@tetsujs/request-log:thrownis the class of an error that sets nonameof its own, as documented.class NotFound extends Error {}was logged as"Error". A name the error does set, such as aDOMException's"AbortError", is kept.@tetsujs/core: a hook whose slot type was widened, by anAnyHookannotation for one, fails with a message that says so. The message listed every slot instead, one of them saying the slot the hook was put in cannot hold it.@tetsujs/core:ws()refuses a malformed path where it is declared, asroute()does. A path the compiler did not see as a literal, such as"/chat/{id}"or"/a//b", was registered as it was, and a client could never connect to it.@tetsujs/core: a handler returningPromise<any>compiles, as one returninganyalready did. An async handler that returned untyped data, such as parsed JSON, was refused as a stream.
Full Changelog: v0.6.0...v0.6.1
v0.6.0
Breaking changes
@tetsujs/core: in a route'sbeforeResponse,afterResponseandonErrorhooks, a part a schema owns —body,query,headers,cookies— is typed as whatever the request got to: the schema's output, what the client sent when validation refused it, or what a hook before the handler put there. A JSON body there isunknown. It was typed as validated.paramsand a body parsed without a schema follow the same rule, a hook's value included.@tetsujs/core: in anafterResponsehookctx.resis aSentResponse— what the response says, its status and headers, without its body orclone(). An observer starts before Bun sends the response, and one reading the body broke it: a JSON body became a500from Bun, a streamed one reached the client empty. Reading it is now a compile error.@tetsujs/rate-limit: a limiter given astoreneeds aname, and only such a limiter takes one. The store finds a counter by its key, and the key was the client alone: two limiters on one store counted into one counter, so a login allowed five an hour lived in the window of a global limit allowed a hundred a minute. The key is now the name, then the client. One name on one store with other settings is refused when the second limiter is made.@tetsujs/typebox: a DTO nested in another with an option the outer one does not have —convert,clean,defaults— is refused where the outer one is made. A schema is checked with the options of thetb()around it, so aPublicUserdeclared withclean: trueand listed in a page of users used to lose itscleanwithout a word, and the fields it was declared to strip went out in the response. A schema derived from a DTO —Type.Pick,Type.Omit,Type.Partial— is a new schema and carries none of its options.@tetsujs/core: theafterResponseobservers of a request start in order, each without waiting for the one before. An observer started only once an async one before it had settled started after the response was sent, and found the URL, the headers and the address gone —accessLog()behind an async observer wrote nothing, and reported anInvalid URLon every request. An observer that counted on the one before it having finished takes its result in the same hook, or awaits a promise the other left.@tetsujs/cors: an origin a browser would never send — a trailing slash, capitals, a path, a default port — is refused where it is written, naming the origin it means, and so is a pattern such ashttps://*.example.com. Neither ever matched, and every request from the site was refused with nothing at startup to say why. An app or extension scheme —capacitor://localhost— is taken as written. So is"null"together withcredentials, which let any site make credentialed requests: a sandboxed frame sends it.@tetsujs/core: a value the handler returns under a status its map declares without a body —null, or an entry withoutbody— is refused with a500when responses are validated. It was sent past every schema: in a map with200: User, auserreturned under303went out whole, the fieldsUserstrips included. The compiler catches it only when no status of the map has a body.
Added
@tetsujs/core: a status of a response map can declare the headers and cookies it leaves with —201: { body: Order, headers: Created },204: { cookies: SignedIn }— the way a request declares its parts. They are checked with the body when responses are validated, a response that breaks them refused with a500, and@tetsujs/openapidocuments them: each header its schema names, required as it says, and the cookies as theset-cookieheader that sets them.@tetsujs/openapi/testing:assertDescribedreports a header the status requires and the response does not carry.@tetsujs/rate-limit:perRoute: truegives each route a budget of its own from one limiter — twenty a minute on every endpoint, mounted once on the application. A route is its template; requests no route answers share one budget.@tetsujs/lifecycle:onShutdownSignalsreturnsdrainingtoo, a signal that fires when the server starts to stop — after the pre-stop delay.@tetsujs/sse:sse()andstream()takeuntil, a signal that ends the stream. Together they close the streams a stopping server would otherwise wait for:server.stop()waits for every response in flight, and an event stream held every deploy for the whole grace period before it was cut and the process exited with1.@tetsujs/request-log: an access record carriesaborted: truewhen the connection closed before the response was ready — the client left, or a forced stop cut it — with the status the server answered. It read as an ordinary success.@tetsujs/core:signedCookie(ctx, name)reads a signed cookie with its seal checked, in any hook — before the body is read, wherectx.cookiesis not filled yet. It reads the first value of the name whose seal holds, as the core does. A hook that authenticated before the body had nothing to check the seal with, and a rate limit keyed by the sealed string gave a client a new budget for every junk value put in front of the real one.@tetsujs/rate-limit:slotruns a limiter inbeforeValidation— the body parsed, not yet validated — orbeforeHandlerather thanbeforeParse. InbeforeHandleits key reads the validated body — a limit by the account a login names, which holds against guessing a password from many addresses — or a user a hook looked up. The compiler keeps it in that slot.@tetsujs/core:ws()takesuntil— a signal, or a function asked as a socket opens — and closes the endpoint's sockets with1001when it fires, and a socket opened after it at once. Withdrainingfrom@tetsujs/lifecyclea deploy no longer waits out the grace period for an open socket, cuts it with1006, and exits as a forced stop.
Fixed
@tetsujs/openapi: the Swagger UI page fetches the document from the address it was given when that address has a query of more than one parameter. The address was escaped for HTML inside a script, where&stays&; Swagger UI now reads it from an attribute, as Scalar and Redoc do.@tetsujs/openapi/testing:assertDescribedfinds no operation for a path longer than every template it could be, where it used to check the response against a template ending in a parameter —/users/1/2/3against/users/{id}. Only{wildcard}, a trailing*, takes the rest of a path.@tetsujs/openapi/testing:assertDescribedreports an empty body where the status describes one. It passed it, whatever the document said.@tetsujs/openapi: the README says that adocs()documents the whole application it is mounted in, and that several surfaces with a document each are several applications. It described them as one application with severaldocs(), which does not start: two controllers of one application cannot share a name.@tetsujs/openapi: a route that validates only its cookies documents the422it answers when they fail; a schema on the cookies alone did not count as one on a request part.@tetsujs/openapi: a route withrawBody: trueand no body schema documents the JSON body it parses, and the400and413it answers when that body is not JSON or too large. Only a body schema or abodyTypecounted as a body.@tetsujs/openapi: a response header or cookie whose schema gives it a default is documented as optional. The response carries what the handler set, not what the schema returns, so the default is never sent; the document read the schema's output, where the field is filled, and called it required.@tetsujs/openapi: the README's recipe for an error format of one's own answers an unexpected error in that format too, and reports it withreportFailure, sincereportErrorhears only of what no hook answered. Its hook left such errors to the framework, whose500the document described in the application's format; its contract test, now onassertDescribed, provokes the500as well.@tetsujs/core:ctx.routeis not optional in thebeforeResponse,afterResponseandonErrorhooks of a route, where it is always set; a hook asking for it withRequires<{ route: RouteInfo }>mounts there.@tetsujs/core: a route that parses its body without a schema — abodyTypeorrawBody— typesctx.bodyfrombeforeValidationon as what parsing produced, or what abeforeValidationhook made of it. A body abeforeParsehook returned stayed in the type, and parsing replaced it at runtime.@tetsujs/openapi: a route whose map declares no2xx— only a redirect, say — is documented with the statuses it declares. A200nobody declared was added, and a client generated from the document waited for it.@tetsujs/core: frames of a socket reachmessagein the order they arrived whenschema.messagechecks asynchronously, and none after the socket closed. Each frame waited on its own check, so one checked faster overtook one checked slower, and a frame refused at once closed the socket under the one before it, which then arrived afterclose.@tetsujs/core: aHEADrequest states thecontent-lengthaGETwould send. TheGETresponse was rebuilt without its body, and the length Bun computes as it sends came out0on every route; now Bun answers theHEADfrom the response itself.@tetsujs/core: a route at/under a group is"GET /api/users"inAppRoutes, the path it is served at. The type joined a trailing slash Bun never matches. A route under a symbol key, which the types listed and the table never served, is refused at startup.@tetsujs/core:group()refuses?,{and}in a prefix at startup, asroute()does in a path; the compiler refused them only in a literal, and a prefix from configuration served every route under it as a404.- `...
v0.5.3
A test client that keeps its headers and cookies from one request to the next, and recipes in the README for request metrics, health checks and reporting a failed background job.
Added
@tetsujs/core/testing:serve(app).client({ headers })is a client that sends its own headers with every request and keeps a cookie jar: the cookies a response sets go back with the next requests where theirPathmatches, and a response that deletes or expires one removes it.jsonsends a value as JSON, and a header set tonullis not sent.client.cookiesreads and plants values.
Fixed
@tetsujs/lifecycle: the readiness example in the README built its route after the server, where it could not be mounted.
Full Changelog: v0.5.2...v0.5.3
v0.5.2
Descriptions for the document's tags, a check that holds a response to the document in a test, and the raw bytes of a body for a webhook's signature; a group is eight times cheaper for the type checker.
Added
@tetsujs/openapi:tagsinopenapi()anddocs()— what each tag is, by name, in the order a renderer lists the sections. A tag the routes use andtagsleaves out is listed after them and reported, and so is a described tag no operation uses: either is usually one tag spelled two ways.docs()describes its own tag when its routes are documented.@tetsujs/openapi/testing:assertDescribed(document, "POST /session", res)throws unless a response a test provoked is one the document describes for the operation — its status declared, its body one of the alternatives of that status, the headers asked about documented — and lists every problem at once.validatechecks the body in full with a JSON Schema validator of your choice.@tetsujs/core:rawBody: trueon a route keeps the bytes of ajsonortextbody asctx.rawBody, next to the body parsed and validated from them — for a webhook signed over its bytes. The field is typed only on a route that asks, so a hook requiring it withRequirescannot be mounted where it is missing; a form or a stream withrawBodyis refused.rawBodyis now the pipeline's own field: a hook that returned a field of that name no longer adds it to the context.
Changed
@tetsujs/core: agroup()costs the type checker about 130 instantiations where it cost about 1 090 — an application of 100 groups and 200 routes 194 k where it was 288 k, and 83 MB where it was 114. The children met the configuration's ownchildrenas an intersection of two array types, and every method of an array was built once per group. Its options besides the children areGroupOptions.@tetsujs/core: the package no longer ships the test helpers of this repository that@tetsujs/core/testingdoes not export — type assertions, a mock schema, a socket client. They were never importable.
Full Changelog: v0.5.1...v0.5.2
v0.5.1
Changed
@tetsujs/openapi: a status the route declares is described by thedescriptionof its schema — for any status, a200as much as an error — where it used to get only its reason phrase, and the schema's description reached only its definition incomponents. Several descriptions under one status — the route's, a hook's, the framework's — are a list, each item led by its code, where they used to be one line joined by;.
Full Changelog: v0.5.0...v0.5.1
v0.5.0
Every failure reaches the application's onError hooks — a 404, a 405 and a rate limit's refusal included — so one hook sets the format of every error, and errors in @tetsujs/openapi describes that format in the document.
Breaking changes
@tetsujs/core:404and405reach the application'sonErrorhooks as anHttpError, like every other failure. AnonErrorhook that answers every error — or logs each one — now sees unmatched paths and methods too. An application withoutonErrorhooks answers them as before, with the same response and at the same cost.@tetsujs/rate-limit: a refusal is a thrownHttpErrorwithretryAfterin its body, and reaches the application'sonErrorhooks. It used to be a returnedResponsethatonErrornever saw.
Added
@tetsujs/openapi:errorsinopenapi()anddocs()— an error format of the application's own, for the document to describe the framework's failures, the hooks' refusals and the routes' own envelopes in it.schemadescribes one failure from its status, code, message and fields;discriminatornames the top-level field with the code;codereads the code from a route's schema, for a format that nests it. A definition that differs from the kept one is now compared at every depth, so a nested format's missing field is reported by its path.
Changed
@tetsujs/core: the error path is synchronous until something on it waits, as the success path is. Every failure theonErrorhooks see takes it — a thrownHttpError, a validation failure, a refusal — and each now costs 110–170 ns less, a refusal through the pipeline 776 ns where it was 880.
Moving from 0.4
An onError hook on the application now receives 404, 405 and a rate limit's 429 as an HttpError. A hook that maps only errors it knows — returning nothing for the rest — needs no change: the default envelope answers as before. A hook that answers or logs every error should check error.status if those three are not meant for it.
Full Changelog: v0.4.2...v0.5.0
v0.4.2
The generated document describes each error once: every envelope is one definition per status and code, whoever declared it, and a status of envelopes can be discriminated by its code.
Added
@tetsujs/openapi:fieldsandheaderson a response passed todocumented()— what a hook adds to the error envelope, and the headers it sets. The envelope stays one definition incomponents, with the fields in it. Both take the newJsonSchematype, JSON Schema 2020-12 keyword by keyword: a misspelled keyword or an unknowntypedoes not compile.@tetsujs/openapi: a status whose alternatives are all error envelopes has adiscriminatoronerror, mapping each code to its definition, so a generated client narrows on the code.@tetsujs/openapi:docs({ ui: false })serves the document without a page — for an origin that carries a session, where the page would run a CDN's code as the signed-in user.assetstakesintegrityhashes for a renderer of your own.@tetsujs/rate-limit:keyreads what earlierbeforeParsehooks returned, typed withRequires— a client address worked out once, for the limiter and whatever else needs it. The limiter then demands the field where it is mounted, like any hook withRequires.@tetsujs/lifecycle:onShutdownSignals()andshutdown()take a list of servers — one process serving several surfaces. They drain within one grace period, only a server still draining is cut, and the closers run once, after the last server.@tetsujs/core/testing:serve(app, { hostname }). Bun listens on both IPv4 and IPv6 by default and reports an IPv4 client as::ffff:127.0.0.1;hostname: "127.0.0.1"makes a test an IPv4 client, for checks that compare against127.0.0.1.
Changed
@tetsujs/openapi: the default renderers are pinned to an exact version and carry a Subresource Integrity hash — Scalar 1.72.1, Swagger UI 5.33.0, Redoc 2.5.4. Scalar used to load whatever version was latest.@tetsujs/openapi: every error envelope is one definition incomponentsper status and code, named after the code — a route's own included, which used to be inlined next to a named twin from a hook or the framework. The route's definition is the one kept; a definition with different fields is reported as a warning. A client regenerated from the document gets named types where it had anonymous ones.
Fixed
@tetsujs/openapi: the page ofdocs()mounted in a group fetched the document from the path as configured, without the group's prefix, and showed nothing.@tetsujs/openapi: a status and code declared by both the route and a hook was listed twice under the status, and a union the route declared was nested inside the status'sanyOfinstead of joining it.@tetsujs/openapi: a status the route declared and a hook or the framework described was documented as"Response 403; <their description>". The placeholder is left out when something else describes the status, and a status only the route declares is named by its reason phrase —"Not Found", not"Response 404".@tetsujs/rate-limit: the documented429now has theretryAfterfield and theretry-afterheader the refusal carries; the document described the bare envelope.
Full Changelog: v0.4.1...v0.4.2
v0.4.1
Added
@tetsujs/openapi:secured(hook, { anyOf: [a, b] })for a hook that accepts any one of several credentials — a session cookie or a bearer token. The document lists every combination a client may bring, each alternative together with the schemes of the route's other hooks.mutualTLSamong the security scheme types, as OpenAPI 3.1 has it.
Fixed
@tetsujs/openapi: a route guarded by severalsecured()hooks was documented as needing any one of their schemes — onesecurityentry per hook, which OpenAPI reads as alternatives. Every hook runs, so every scheme is required: they are one entry now. Two hooks of one scheme with different scopes kept only the first one's scopes; they require both.
Full Changelog: v0.4.0...v0.4.1