Skip to content

Releases: tetsujs/tetsu

v0.6.3

Choose a tag to compare

@github-actions github-actions released this 06 Oct 21:26
v0.6.3
1b98554

Added

  • @tetsujs/static, a new package: staticFiles() serves the files of a directory from fallback or 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 has ETag and Last-Modified with 304, and Bun answers Range. notFound sends a page of the site to a browser, spa the app's shell, and precompressed the .br or .gz copy beside a file. The route stays out of the OpenAPI document unless it says docs: { 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 a Response it 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 a string counts as not JSON, and where it does not see the key at all, it is a 500 when 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 its body as the schema, instead of application/json. So is a response documented() gives a hook, with a contentType of 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, and hidden: true keeps the route out of the document unless the route says docs: { hidden: false }.
  • @tetsujs/openapi: assertDescribed takes a body under a range of media types, such as image/* or */*, and compares types without regard to case. It parses a body as JSON only under application/json or a +json type, and takes an empty body under any other.

Fixed

  • @tetsujs/core: a route with a query schema answers a request without a usable Host, such as an HTTP/1.0 health check, instead of failing it with a 500. Bun leaves req.url relative then, and the query is read against a placeholder origin. A Host such as [ makes req.url no URL at all, and such a request has no query.
  • @tetsujs/request-log: for the same requests, arrivalLog() no longer turns the request into a 500, and accessLog() writes its record instead of reporting a failure. The path is read from a relative req.url too, and a req.url that 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 as 4XX or default, or with an entry that is neither a schema, null nor an object of parts, is refused where the route is declared. It passed: a key such as 600 matched no response and was documented as one, an entry such as a bare string made every validated response of its status a 500, an undefined entry went unchecked, and openapi() threw a TypeError on either of the last two. default and 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 below 400 without a schema has no body in the document — a redirect, or the refusal of a secured() 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 answer 403 Forbidden with codes of their own, such as NOT_OWNER and PLAN_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 different fields give a warning, where one was dropped without a word.
  • @tetsujs/openapi: default and a range such as 4XX in a response map are described as what they are, Any other response and Client error, not as HTTP NaN.

Full Changelog: v0.6.2...v0.6.3

v0.6.2

Choose a tag to compare

@github-actions github-actions released this 05 Oct 22:00
v0.6.2
5168974

Added

  • @tetsujs/core: serve(app, { stop: false }) leaves the server running for the caller, and request.stop() stops it. A server started in beforeAll needs both: Bun runs an afterAll registered inside a hook as soon as the hook returns, so the one serve() 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 at llms.txt, which is only the index (#75).

Fixed

  • @tetsujs/sse: a stream with a keep-alive — every sse() by default, a stream() 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, or 0, and one the handler set itself, such as the server.timeout(req, 0) of Bun's own guide; a generator that wants another sets its own. A quiet feed now stays open, so one without until holds a stopping server for its whole grace period: end it on draining. On a unix socket, where Bun ignores a request's timeout, the heartbeat has to stay under 8 seconds.
  • @tetsujs/sse: a heartbeatMs, or a keep-alive's everyMs, that is NaN, Infinity, a negative, over 2³¹ − 1 or not a number at all is refused with a TypeError where the stream is made. Read as none, a NaN heartbeat — what Number() of an unset variable gives — let Bun close every quiet feed, and Infinity made a timer beat every millisecond. A string, read from JSON say, used to pass as the number it spelled.
  • @tetsujs/core: a server serve() started says what stopped it — its own afterAll, request.stop() or stopServers() — when a request, a client or request.url reaches it afterwards. One started in beforeAll failed every request with a bare ConnectionRefused.
  • @tetsujs/core: in testCtx(), 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 of stopping and draining are written in the order an application can be: the route, then the server, then onShutdownSignals(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() sends x-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

Choose a tag to compare

@github-actions github-actions released this 01 Oct 21:33
v0.6.1
5f49904

Fixed

  • @tetsujs/request-log: thrown is the class of an error that sets no name of its own, as documented. class NotFound extends Error {} was logged as "Error". A name the error does set, such as a DOMException's "AbortError", is kept.
  • @tetsujs/core: a hook whose slot type was widened, by an AnyHook annotation 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, as route() 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 returning Promise<any> compiles, as one returning any already 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

Choose a tag to compare

@github-actions github-actions released this 30 Sep 11:11
v0.6.0
dd020fb

Breaking changes

  • @tetsujs/core: in a route's beforeResponse, afterResponse and onError hooks, 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 is unknown. It was typed as validated. params and a body parsed without a schema follow the same rule, a hook's value included.
  • @tetsujs/core: in an afterResponse hook ctx.res is a SentResponse — what the response says, its status and headers, without its body or clone(). An observer starts before Bun sends the response, and one reading the body broke it: a JSON body became a 500 from Bun, a streamed one reached the client empty. Reading it is now a compile error.
  • @tetsujs/rate-limit: a limiter given a store needs a name, 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 the tb() around it, so a PublicUser declared with clean: true and listed in a page of users used to lose its clean without 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: the afterResponse observers 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 an Invalid URL on 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 as https://*.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 with credentials, 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 without body — is refused with a 500 when responses are validated. It was sent past every schema: in a map with 200: User, a user returned under 303 went out whole, the fields User strips 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 a 500, and @tetsujs/openapi documents them: each header its schema names, required as it says, and the cookies as the set-cookie header that sets them.
  • @tetsujs/openapi/testing: assertDescribed reports a header the status requires and the response does not carry.
  • @tetsujs/rate-limit: perRoute: true gives 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: onShutdownSignals returns draining too, a signal that fires when the server starts to stop — after the pre-stop delay. @tetsujs/sse: sse() and stream() take until, 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 with 1.
  • @tetsujs/request-log: an access record carries aborted: true when 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, where ctx.cookies is 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: slot runs a limiter in beforeValidation — the body parsed, not yet validated — or beforeHandle rather than beforeParse. In beforeHandle its 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() takes until — a signal, or a function asked as a socket opens — and closes the endpoint's sockets with 1001 when it fires, and a socket opened after it at once. With draining from @tetsujs/lifecycle a deploy no longer waits out the grace period for an open socket, cuts it with 1006, 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 &amp;; Swagger UI now reads it from an attribute, as Scalar and Redoc do.
  • @tetsujs/openapi/testing: assertDescribed finds 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/3 against /users/{id}. Only {wildcard}, a trailing *, takes the rest of a path.
  • @tetsujs/openapi/testing: assertDescribed reports an empty body where the status describes one. It passed it, whatever the document said.
  • @tetsujs/openapi: the README says that a docs() 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 several docs(), which does not start: two controllers of one application cannot share a name.
  • @tetsujs/openapi: a route that validates only its cookies documents the 422 it answers when they fail; a schema on the cookies alone did not count as one on a request part.
  • @tetsujs/openapi: a route with rawBody: true and no body schema documents the JSON body it parses, and the 400 and 413 it answers when that body is not JSON or too large. Only a body schema or a bodyType counted 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 with reportFailure, since reportError hears only of what no hook answered. Its hook left such errors to the framework, whose 500 the document described in the application's format; its contract test, now on assertDescribed, provokes the 500 as well.
  • @tetsujs/core: ctx.route is not optional in the beforeResponse, afterResponse and onError hooks of a route, where it is always set; a hook asking for it with Requires<{ route: RouteInfo }> mounts there.
  • @tetsujs/core: a route that parses its body without a schema — a bodyType or rawBody — types ctx.body from beforeValidation on as what parsing produced, or what a beforeValidation hook made of it. A body a beforeParse hook returned stayed in the type, and parsing replaced it at runtime.
  • @tetsujs/openapi: a route whose map declares no 2xx — only a redirect, say — is documented with the statuses it declares. A 200 nobody declared was added, and a client generated from the document waited for it.
  • @tetsujs/core: frames of a socket reach message in the order they arrived when schema.message checks 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 after close.
  • @tetsujs/core: a HEAD request states the content-length a GET would send. The GET response was rebuilt without its body, and the length Bun computes as it sends came out 0 on every route; now Bun answers the HEAD from the response itself.
  • @tetsujs/core: a route at / under a group is "GET /api/users" in AppRoutes, 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, as route() does in a path; the compiler refused them only in a literal, and a prefix from configuration served every route under it as a 404.
  • `...
Read more

v0.5.3

Choose a tag to compare

@github-actions github-actions released this 27 Sep 20:24
v0.5.3
8dfe469

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 their Path matches, and a response that deletes or expires one removes it. json sends a value as JSON, and a header set to null is not sent. client.cookies reads 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

Choose a tag to compare

@github-actions github-actions released this 27 Sep 16:59
53dd8ee

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: tags in openapi() and docs() — what each tag is, by name, in the order a renderer lists the sections. A tag the routes use and tags leaves 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. validate checks the body in full with a JSON Schema validator of your choice.
  • @tetsujs/core: rawBody: true on a route keeps the bytes of a json or text body as ctx.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 with Requires cannot be mounted where it is missing; a form or a stream with rawBody is refused. rawBody is 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: a group() 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 own children as an intersection of two array types, and every method of an array was built once per group. Its options besides the children are GroupOptions.
  • @tetsujs/core: the package no longer ships the test helpers of this repository that @tetsujs/core/testing does 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

Choose a tag to compare

@github-actions github-actions released this 27 Sep 10:18
41a59db

Changed

  • @tetsujs/openapi: a status the route declares is described by the description of its schema — for any status, a 200 as much as an error — where it used to get only its reason phrase, and the schema's description reached only its definition in components. 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

Choose a tag to compare

@github-actions github-actions released this 27 Sep 06:07
89956f3

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: 404 and 405 reach the application's onError hooks as an HttpError, like every other failure. An onError hook that answers every error — or logs each one — now sees unmatched paths and methods too. An application without onError hooks answers them as before, with the same response and at the same cost.
  • @tetsujs/rate-limit: a refusal is a thrown HttpError with retryAfter in its body, and reaches the application's onError hooks. It used to be a returned Response that onError never saw.

Added

  • @tetsujs/openapi: errors in openapi() and docs() — 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. schema describes one failure from its status, code, message and fields; discriminator names the top-level field with the code; code reads 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 the onError hooks see takes it — a thrown HttpError, 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

Choose a tag to compare

@github-actions github-actions released this 26 Sep 19:25
cce3631

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: fields and headers on a response passed to documented() — what a hook adds to the error envelope, and the headers it sets. The envelope stays one definition in components, with the fields in it. Both take the new JsonSchema type, JSON Schema 2020-12 keyword by keyword: a misspelled keyword or an unknown type does not compile.
  • @tetsujs/openapi: a status whose alternatives are all error envelopes has a discriminator on error, 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. assets takes integrity hashes for a renderer of your own.
  • @tetsujs/rate-limit: key reads what earlier beforeParse hooks returned, typed with Requires — 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 with Requires.
  • @tetsujs/lifecycle: onShutdownSignals() and shutdown() 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 against 127.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 in components per 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 of docs() 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's anyOf instead 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 documented 429 now has the retryAfter field and the retry-after header the refusal carries; the document described the bare envelope.

Full Changelog: v0.4.1...v0.4.2

v0.4.1

Choose a tag to compare

@github-actions github-actions released this 25 Sep 11:51
9beabf7

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.
  • mutualTLS among the security scheme types, as OpenAPI 3.1 has it.

Fixed

  • @tetsujs/openapi: a route guarded by several secured() hooks was documented as needing any one of their schemes — one security entry 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