Repository navigation
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.@tetsujs/core:route()refuses a method it cannot serve at startup — a lower-case"get", which never ran and was advertised in the405'sAllow, or aHEADorOPTIONS, which took over what every path answers itself.- The README and
hook.afterResponsesaid 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 firstawait— 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-logand@tetsujs/sse. @tetsujs/core: abeforeResponsehook failing over an error response is reported once, as"unhandled", and not at all whenonErroranswers it or it threw anHttpError— as the same hook failing over a response is. The error path used to report it as"errorResponse"and then again, and to report anHttpErrorthrown on purpose."errorResponse"is now the failure the error path had no attempt left to answer — anHttpErrorwhose body throws when serialized.@tetsujs/core:hook.onErrortakes a function that returns aResponseor nothing. One returning an object —{ status: 409 }, the way another framework maps an error — used to compile, and the runtime, which takes only aResponsefrom this slot, answered with a500.@tetsujs/core: an empty or missingcookies.secretis 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 a500when 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 inctx.cookies, asreq.cookies.getreads 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 a422. The raw value used to reach the handler as if it were valid.@tetsujs/core: ajsonortextbody 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 a400.@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'slazy. 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=awas refused with a422. @tetsujs/rate-limit: awindowMsthat is not a positive, finite number, and alimitthat 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 — and0used to start a new window on every request, so the limiter refused nothing while its headers reported a budget.@tetsujs/rate-limit: a refusal'sretry-afteris 1 second at least. A store that answered with a window already over made it0, 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 whoseDecodethrows on what the client sent fails the value with a422carrying the error's message, andparse()throws aValidationError. It used to answer500: any client could cause one with a stringBigIntcannot read.@tetsujs/typebox: a property namederrorMessagestays in the JSON Schema a DTO emits, and so does that key inside adefault,examples,const,enum,dependentRequiredor anx-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'sAbortErrorwhen the client leaves — asfetch,events.onand a timer fromnode:timers/promisesdo once handed the signal — ends the stream as"cancelled", and nothing is reported. Every ordinary disconnect used to reachreportErroras a failed stream.@tetsujs/sse: a generator whosefinallythrows while the stream is being cancelled is reported withsource: "stream". The rejection went unhandled, and Bun ended the process with every other connection in it.@tetsujs/sse:sse()opens with a: opencomment, 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'sEventSourcein "connecting" until the first event or keep-alive — up to 15 seconds, or for good withheartbeatMs: 0.@tetsujs/lifecycle: a server whosestopthrows is a failure in the result, forced, and the closers still run.shutdown()used to reject past its closers, andonShutdownSignalscrashed on the unhandled rejection before they ran.@tetsujs/lifecycle: the documentation ofexit: falsesays 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'sretrythat is not a whole number of milliseconds is refused, as a line break in itsidis. A browser ignores any other value without a word, and aNaNfrom a variable that is not set went out as the delay the stream believed it had set.@tetsujs/core: a response'svaryfrom the handler and from a hook are merged, each token once. The hook's overwrote the handler's: a handler's ownResponsewithVary: Cookieundercors()left withvary: originalone, and a shared cache served one user's response to another.@tetsujs/cors: every answer saysvary: originunless the origin is"*", those without CORS headers too. A cache that stored an answer to a request with noOriginas 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 offiles()says it isundefinedunderType.Optionalwhen 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 changesframe-ancestorsin the policy too, which a browser follows overx-frame-options; withapiPolicythe 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
moduleResolutionisbundler,node16ornodenext. Under the oldnodemode, which does not readexports,@tetsujs/corewas not found, with nothing to say why. @tetsujs/core: a signed cookie a hook returned incookiesis 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 unsignedsession: "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 withsignedCookie()can be unit-tested.- The guide shows a set of hooks several places share spread into each slot,
as const, instead of a helper joininghooksobjects: the order is checked as it was, and the slot shows what runs. Theopenapierror format example imports what it uses; thex-forwarded-forrecipe 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 answers401, 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