Skip to content

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 30 Sep 11:11
· 25 commits to main since this release
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 &; 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.
  • @tetsujs/core: route() refuses a method it cannot serve at startup — a lower-case "get", which never ran and was advertised in the 405's Allow, or a HEAD or OPTIONS, which took over what every path answers itself.
  • The README and hook.afterResponse said observers run after the response is sent. They start as it goes to Bun: their synchronous part is part of the response's latency, and what they need of the request or the response they read before their first await — after it, a URL, a header or the client's address nobody read may be gone, without an error. The documentation says so, with a recipe for each, and so do @tetsujs/request-log and @tetsujs/sse.
  • @tetsujs/core: a beforeResponse hook failing over an error response is reported once, as "unhandled", and not at all when onError answers it or it threw an HttpError — as the same hook failing over a response is. The error path used to report it as "errorResponse" and then again, and to report an HttpError thrown on purpose. "errorResponse" is now the failure the error path had no attempt left to answer — an HttpError whose body throws when serialized.
  • @tetsujs/core: hook.onError takes a function that returns a Response or nothing. One returning an object — { status: 409 }, the way another framework maps an error — used to compile, and the runtime, which takes only a Response from this slot, answered with a 500.
  • @tetsujs/core: an empty or missing cookies.secret is refused at startup. The application used to start and sign with a key anyone can compute — an empty one, or none at all when the variable was unset — so a forged cookie read as signed.
  • @tetsujs/core: a signed cookie whose forged signature has a non-ASCII character reads as absent, as any bad signature does. It used to fail the request with a 500 when the signature was as long as a real one in characters but not in bytes.
  • @tetsujs/core: a cookie name the request carries twice reads as its first value in ctx.cookies, as req.cookies.get reads it — the host's own cookie, which a browser sends before one a sibling subdomain set for the whole domain. It used to read as the last. Of a signed name, the first value whose seal holds.
  • @tetsujs/core: a schema that refuses a request part with an empty list of issues — which Standard Schema allows — fails the request with a 422. The raw value used to reach the handler as if it were valid.
  • @tetsujs/core: a json or text body that starts with a byte order mark reads without it however it was sent. Sent in chunks, the mark used to stay, and a JSON body failed with a 400.
  • @tetsujs/openapi: a reference in a validator's schema that leads nowhere once the schema is embedded is reported as a warning — the # of a recursive Zod schema, the #/$defs/… of a named one, of an ArkType scope, of Valibot's lazy. The document used to be invalid without a word.
  • The guide says that a query or form key sent once arrives as a string, and shows the schema taking one value for an array. It did not, and an array parameter documented as ?tag=a was refused with a 422.
  • @tetsujs/rate-limit: a windowMs that is not a positive, finite number, and a limit that is not a whole number of 0 or more, are refused when the limiter is made. NaN — Number() of a variable that is not set — and 0 used to start a new window on every request, so the limiter refused nothing while its headers reported a budget.
  • @tetsujs/rate-limit: a refusal's retry-after is 1 second at least. A store that answered with a window already over made it 0, and a client that followed it retried at once.
  • @tetsujs/rate-limit: the README's recipes limit what they say they do. The first example counted by a session cookie, which a client can leave out — skipping the limit — or change on every request, for a new budget each time; it counts by the client's address now. The allowance for internal callers was a header any client can send; it is an address on a list now. The Redis store set the expiry only on the first hit, and a timeout between its two commands locked the client out for good; it sets it on every hit, if there is none.
  • @tetsujs/typebox: a codec whose Decode throws on what the client sent fails the value with a 422 carrying the error's message, and parse() throws a ValidationError. It used to answer 500: any client could cause one with a string BigInt cannot read.
  • @tetsujs/typebox: a property named errorMessage stays in the JSON Schema a DTO emits, and so does that key inside a default, examples, const, enum, dependentRequired or an x- extension. The keyword was dropped wherever the name appeared, leaving a document that required a property it did not describe.
  • @tetsujs/typebox: a failure under a record key that looks like a number — "0" — has the key in its path as a string. It used to be a number, like an array index.
  • @tetsujs/sse: a source that rejects with the signal's AbortError when the client leaves — as fetch, events.on and a timer from node:timers/promises do once handed the signal — ends the stream as "cancelled", and nothing is reported. Every ordinary disconnect used to reach reportError as a failed stream.
  • @tetsujs/sse: a generator whose finally throws while the stream is being cancelled is reported with source: "stream". The rejection went unhandled, and Bun ended the process with every other connection in it.
  • @tetsujs/sse: sse() opens with a : open comment, so the status and headers go out at once. Bun sends them with the first chunk of the body, and a feed with nothing to say yet kept a browser's EventSource in "connecting" until the first event or keep-alive — up to 15 seconds, or for good with heartbeatMs: 0.
  • @tetsujs/lifecycle: a server whose stop throws is a failure in the result, forced, and the closers still run. shutdown() used to reject past its closers, and onShutdownSignals crashed on the unhandled rejection before they ran.
  • @tetsujs/lifecycle: the documentation of exit: false says that a third signal ends the process anyway, as it always did — the way out of a shutdown that hangs.
  • @tetsujs/sse: a stream starts its generator and keep-alive when it is first read, stream()'s too. A stream made by a handler that then threw was sent nowhere, and its timer beat for as long as the process lived.
  • @tetsujs/sse: an event's retry that is not a whole number of milliseconds is refused, as a line break in its id is. A browser ignores any other value without a word, and a NaN from a variable that is not set went out as the delay the stream believed it had set.
  • @tetsujs/core: a response's vary from the handler and from a hook are merged, each token once. The hook's overwrote the handler's: a handler's own Response with Vary: Cookie under cors() left with vary: origin alone, and a shared cache served one user's response to another.
  • @tetsujs/cors: every answer says vary: origin unless the origin is "*", those without CORS headers too. A cache that stored an answer to a request with no Origin as the same for everyone handed it to the allowed site, and the browser refused it.
  • @tetsujs/core: a file input nothing was chosen in — sent by a browser as a file with no name and no bytes — is left out of a form body, as a field that is not there. An optional file was refused for the type of a file nobody chose, on every ordinary HTML form. Two inputs of one name with one left empty now give one file rather than two, as a key sent once gives one value: a schema takes a single value for a list, as the guide's "Validation" shows.
  • @tetsujs/typebox: the documentation of files() says it is undefined under Type.Optional when nothing was chosen, as its type does. It said the value was always an array.
  • @tetsujs/secure-headers: the README's exception that allows a frame changes frame-ancestors in the policy too, which a browser follows over x-frame-options; with apiPolicy the page stayed unframeable. The docs page exception compares the path the page is mounted at, prefix and all, and no longer only /docs.
  • The guide's install section says that TypeScript's moduleResolution is bundler, node16 or nodenext. Under the old node mode, which does not read exports, @tetsujs/core was not found, with nothing to say why.
  • @tetsujs/core: a signed cookie a hook returned in cookies is checked as one from the header is, in any slot and on any route: opened, or absent when its seal does not hold. It was taken as it came, and an unsigned session: "admin" from a hook reached the handler as the session. testCtx() takes the application's cookie options as a second argument, so code that signs cookies or reads them with signedCookie() can be unit-tested.
  • The guide shows a set of hooks several places share spread into each slot, as const, instead of a helper joining hooks objects: the order is checked as it was, and the slot shows what runs. The openapi error format example imports what it uses; the x-forwarded-for recipe says what it relies on; the guide says how to test a limiter, that an upload's type is the client's word, how a missing session answers 401, and how a session cookie reaches a frontend on another site.

Moving from 0.5

An observer that hands ctx.res to a function taking a Response, or asks for it with Requires<{ res: Response }>, takes a SentResponse instead — exported by @tetsujs/core — or passes on what it reads, such as ctx.res.status. One that reads the body clones the response in a beforeResponse hook, where the response is still the application's.

A unit test of a rawBody route without a bodyType passes a body to testCtx(): the route parses JSON, so its handler has one.

A response or error hook that asks for a validated part — Requires<{ body?: Order }> — asks for unknown instead, or for the union it now is, and narrows before it trusts the value: that hook also runs for the request the schema refused.

A tb() DTO nested in another, with an option the outer one lacks, is refused at startup: turn the option on for the outer DTO, which then applies it to the nested one too.

An afterResponse observer that relied on the one before it having finished its async part — a value it left in a shared variable — takes that result in the same hook, or awaits a promise the other left: the observers of a request no longer wait for each other.

A handler that returns a value under a status its map declares without a body — 303: null next to 201: Order — returns nothing there: with responses validated, as they are by default, that value is now a 500.

A test that reads an sse() body whole finds : open and a blank line first, and onEnd counts them in bytes, though not in events.

Code that extends RateLimitOptions with an interface uses a type alias and an intersection instead: the options are a union now, since name comes with store and without it. RateLimitHook is the type of a limiter in beforeParse; one made for another slot is ReturnType of its own rateLimit() call.

A rateLimit() given a store is given a name too. A shared store's keys change from the client to the name and the client, so its counters start from zero after the deploy, and keys the old Redis recipe left without an expiry are no longer read — they can be deleted.

Full Changelog: v0.5.3...v0.6.0