1.17.0 — 2026-09-21
Version 1.17.0 closes the two largest usefulness gaps in server — there was no way to serve a file from disk and no way to throttle a request — and makes sanitizeHtml() work on runtimes without a DOM, which had been the one piece of a runtime-agnostic framework that still required a browser. Alongside those, the packaging fixes in this release mean the published tarball resolves correctly for the first time.
It contains no API removals and no module status transitions. One file path changed; see Upgrading.
TIP
This page groups the changes by theme. The full list lives in the CHANGELOG. For how each affected API is meant to be used, follow the module guides linked from each section.
Headlines
serveStatic()— every app needed a reverse proxy just to deliver its ownclient.js. Now it does not.rateLimit()— no server-side throttling primitive existed, so every public endpoint was unprotected against brute force by default, including the login route in the docs' own cookbook.- The sanitizer runs anywhere —
sanitizeHtml()threwReferenceError: document is not definedon Bun, Node and Deno. The server is the most common place to need it. - An opt-in glitch-free scheduler — plus a flush-ordering fix that applies under the default scheduler too.
require('@bquery/bquery')returns the framework — it used to return an empty object, silently, with no error.
Server
serveStatic()
app.use(serveStatic({ root: './public', maxAge: 31536000, immutable: true }));Weak ETag and Last-Modified with 304, single-range requests with 206/416, directory indexes with a 308 for the missing trailing slash, content-type mapping, and optional .br/.gz sidecar lookup. It calls next() for anything it does not serve, so routes still see those requests. File access goes through node:fs, which every runtime listen() supports.
Path traversal is checked twice, and the second check is the one that matters. Decoded segment checks catch .. and its encoded variants, but a symlink inside root pointing outside it passes every string check ever written. The resolved path is therefore realpath'd and re-checked against the realpath'd root before anything is read. Dotfiles are off by default.
Range responses set Vary and a per-encoding ETag, so a shared cache cannot hand a Brotli body to a client that did not ask for one.
See the Server guide.
rateLimit()
app.use(rateLimit({ window: 60_000, max: 10, keyBy: (ctx) => ctx.session.id }));Counters live in a SessionStore — the same abstraction sessions use — so a Redis-backed store plugs in the same way and the limit holds across processes. Responses carry RateLimit-Limit/-Remaining/-Reset; a rejected request gets 429 with Retry-After. skipSuccessfulRequests means a valid login does not spend budget.
keyBy is required, and that is deliberate. ServerContext exposes no peer address, so the only available "client address" is X-Forwarded-For — which the client sets. Defaulting to it would ship a limiter that an attacker bypasses by varying a header, which is worse than no limiter because the app looks protected. trustProxy: true opts into reading it explicitly.
When it does read it, it takes the rightmost entry. The list grows left-to-right as it is forwarded, so only the rightmost hop was added by the proxy closest to you; Cloudflare and nginx append rather than overwrite, and keying on the leftmost entry would hand the client control of the bucket. Behind a chain of proxies the rightmost entry is the inner proxy, so those requests share a bucket — that over-limits rather than under-limits, and a deployment needing per-client buckets behind a chain should pass its own keyBy.
X-Forwarded-For is also the only header it reads. CF-Connecting-IP, True-Client-IP and X-Real-IP are consulted when you name one and not otherwise:
rateLimit({ window: 60_000, max: 10, trustProxy: 'cf-connecting-ip' });Preferring whichever of them happened to be present would reopen the bypass from the other side. A proxy that sets CF-Connecting-IP does not necessarily strip X-Real-IP, and a proxy that appends to X-Forwarded-For touches none of the three — so a client could mint a fresh bucket per request by varying a header its proxy never writes. Naming one is a statement that your proxy sets it on every request; when in doubt, true is the safe choice, because the rightmost X-Forwarded-For hop is proxy-written by construction.
The window is fixed, not sliding, so a burst of up to 2 × max can cross a boundary. That is the standard trade-off for a limiter whose job is to be cheap; a sliding window needs per-request timestamps in the store.
The default memoryStore() only limits one process, so a load-balanced deployment gets N × max unless you pass a shared store.
Middleware now runs for unmatched routes
Global middleware registered with app.use() previously did not run at all for a request that matched no route — the 404 was returned before the stack executed. That made serveStatic as middleware useless, since serving /client.js is precisely a request that matches no route.
The notFound handler is now the end of the middleware chain. Middleware that calls next() is unaffected and the 404 still comes from the same handler, but this is a change to the request lifecycle: middleware that assumed it would never see an unmatched request now will. CORS, logging and security-header middleware were being skipped on exactly the responses where they often matter most.
Security
sanitizeHtml() and stripTags() now work on any runtime:
$ bun -e "import('@bquery/bquery/security').then(m => console.log(m.sanitizeHtml('<b>hi</b>')))"
ReferenceError: document is not defined # before
<b>hi</b> # afterA DOM-free string backend is selected automatically when no DOM is present, mirroring how ssr picks a renderer. configureSanitizer({ backend: 'auto' | 'dom' | 'string' }) and getSanitizerConfig() pin the choice, which matters when server and browser output must match byte for byte.
Both backends call into one shared policy module — allow lists, URL and srcset checks, DOM-clobbering protection, duplicate-id dropping, rel="noopener noreferrer". A second implementation of a security-critical function is only safe if the two cannot drift, and sharing the policy makes that structural rather than a matter of discipline.
Two things to know. The backends can differ on tree construction, because the string one is a scanner rather than a full HTML5 parser: sanitizeHtml('<table><tr><td>x</td></tr></table>') gets an implied <tbody> under DOM and does not under string, and <div><p>a<div>b</p></div> nests differently because <div> implies </p> to a real parser. Both outputs are safe — the policy decides that, and the policy is shared — so the difference matters when server and client output must match byte for byte, not when deciding whether something is inert. Pin backend: 'string' if you need identical output everywhere.
And @bquery/bquery/security grows from 2.8 kB to 4.5 kB gzipped, because both backends ship. Which one runs is decided at runtime, so bundling cannot drop the unused one; a browser-only app pays ~1.7 kB for a scanner it will not call.
No behaviour changed in the browser: the DOM backend is untouched and the existing security suite passes unmodified.
See the Security guide.
Attribute and tag names are validated on the way out
Not part of #229, but found while running CodeQL against the sanitizer and fixed here. Both SSR serializers escaped attribute values and interpolated names raw, and the DOM-free parser ends a name at whitespace, =, > and / — but not at a quote:
renderToString('<div a"b="c">hi</div>');
// <div a"b="c">hi</div> before
// <div>hi</div> afterA stray quote inside a tag leaves the meaning of the markup to the consumer's error recovery. Names are now checked against the same shape the sanitizer uses, at every emission site, rather than trusting the parser's stop set to stay exhaustive. Reachable through the DOM-free string backend; the DOM backend normalises such a name into two attributes.
Output is unchanged for names matching /^[a-zA-Z_:][a-zA-Z0-9_:.-]*$/ (tags: /^[a-zA-Z][a-zA-Z0-9._:-]*$/), which covers ordinary, dashed and namespaced markup. Those patterns are ASCII-only and HTML's are not: <div é="x"> is valid markup whose attribute is now dropped. If you rely on non-ASCII attribute or tag names, this is a behaviour change.
Reactive
configureReactive({ scheduler: 'sync' | 'batched' }), getReactiveConfig() and flushSync(). 'sync' stays the default for all of 1.x.
Under 'batched', a burst of synchronous writes collapses onto one microtask flush. Measured on Bun with 1,000 writes to one signal and 1,000 subscribed effects:
| Scheduler | Time | Effect invocations |
|---|---|---|
'sync' | 1314 ms | 1,000,000 |
'batched' | 44 ms | 1,000 |
The flush-ordering fix applies under the default scheduler too. The pending-observer queue mixed computed revalidations with effects in plain insertion order, so an effect could run between two recomputations and observe a fresh computed beside a stale one:
const double = computed(() => a.value * 2);
const quadruple = computed(() => double.value * 2);
effect(() => seen.push([double.value, quadruple.value])); // saw [4, 4]A flush now drains derivations to a fixed point before running any effect. This changes the ordering that explicit batch() produces under 'sync', so it is a behaviour change outside the opt-in flag — judged a strict correctness improvement, since an effect never reading a half-updated graph is what batch() already promised.
See the Reactive guide.
Packaging
Three problems, all of which shipped in every release up to 1.16.1:
require('@bquery/bquery')returned{}. The package is"type": "module"and the UMD bundle shipped asdist/full.umd.js, so Node read it as ESM; underrequire(esm)the UMD wrapper'stypeof exports == "object"branch never ran. No error — just 638 missing exports. The same bytes as.cjsreturn all 638.- Every emitted declaration used extensionless relative imports, a hard
TS2834for any consumer withskipLibCheck: false. 315 problems, now 0. typeswas declared afterimportin all 23 sub-path entries, and./package.jsonwas not exported.
Sub-paths remain ESM-only. That is a deliberate policy rather than an oversight, and it is now documented in the getting-started guide with workarounds, instead of surfacing as a bare ERR_PACKAGE_PATH_NOT_EXPORTED.
The tarball also lost its JS source maps: 2.8 MB / 11.3 MB unpacked down to 1.3 MB / 5.2 MB. Declaration maps and src/ still ship, so "go to definition" still lands on the real source file.
publint and @arethetypeswrong/cli now run in CI and from prepublishOnly, so none of the above can come back silently.
Upgrading
Package specifiers are unaffected and resolve better than before. One path changed:
| Before | After |
|---|---|
dist/full.umd.js | dist/full.umd.cjs |
unpkg / jsdelivr → UMD build | → dist/full.iife.js |
A pinned https://unpkg.com/@bquery/bquery@1/dist/full.umd.js URL will 404 on this release. Switch it to full.iife.js, which is the build actually intended for script tags and sets the same window.bQuery global:
<script src="https://unpkg.com/@bquery/bquery@1.17.0/dist/full.iife.js"></script>Beyond that, check two behaviour changes if they apply to you:
app.use()middleware now runs for unmatched routes. If any of your middleware assumed it would only ever see a matched request, it will now also run on the path to a 404.batch()runs effects after derivations settle. Code that relied on observing an intermediate state inside a batch will see the settled state instead.- SSR drops attribute and tag names outside the validated ASCII patterns. Ordinary, dashed and namespaced names are unaffected; a non-ASCII name such as
<div é="x">is no longer serialized.