Repository navigation
Version Packages - #12
Merged
Merged
Conversation
github-actions
Bot
force-pushed
the
changeset-release/main
branch
20 times, most recently
from
November 4, 2026 01:40
d529a1b to
2a56104
Compare
github-actions
Bot
force-pushed
the
changeset-release/main
branch
from
November 4, 2026 16:45
2a56104 to
5863952
Compare
cody-dot-js
approved these changes
Nov 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
Releases
@ngrok/app-server@0.3.0
Minor Changes
#14
6f60119Thanks @cody-dot-js! - Add thebodyLimit({ maxBytes })plugin and thebodyLimitoption ofdefaultPlugins. If a request body passes the limit, the client gets a 413. The body still streams to the app, and the plugin does not read it into memory. AmaxBytesthat is not a whole number of 0 or more stops startup. The "Limits" README section says what happens when the app streams the body into its response.Breaking change: with
defaultPlugins(), a request body over 10 MiB now gets a 413. Before, the server had no limit. SetdefaultPlugins({ bodyLimit: 50 * 1024 * 1024 })for a larger limit, orbodyLimit: falseto keep the old behavior.#22
0b536a0Thanks @cody-dot-js! - TheappServerVite plugin now also writes a brotli copy (.<file>.br) next to each gzip copy, when the brotli copy is smaller.app-server servesends the copy that the client'sAccept-Encodingprefers: brotli, then gzip, then the file. It follows theqvalues, andq=0refuses an encoding. In the build of a real docs site, the gzip copies of the 337 scripts are 2.57 megabytes (MB) in all, and the brotli copies are 2.07 MB.Each encoding of a static file now has its own ETag, such as
W/"5fa9c-19a4e2c8f00-br". So a cache that keeps more than one encoding of a file picks the correct one after a 304. A client that holds the ETag of a gzip copy from an earlier version gets one 200 with the new ETag.streamingCompress()now gzips forAccept-Encoding: *. It sends the body as it is whenidentityhas a higherqthan gzip.If you keep the
/assets/*files of old builds for open tabs, also keep their.<file>.brcopies.#14
6f60119Thanks @cody-dot-js! - Answer aRangerequest for a static file. A GET with onebytesrange gets a 206, and a range past the end of the file gets a 416. Safari now plays a<video>from the client build. An HTML file always gets the full page, becauseruntimeEnv()changes its bytes.Breaking change for a host adapter:
StaticFiles.opennow gets an optional byte range. It must send only the bytes fromrange.starttorange.end, both inclusive. If an adapter ignores the range, it sends the whole file in a 206, and a keep-alive connection breaks. Before:open: async (path) => stream(path). After:open: async (path, range) => stream(path, range).#18
a2d8ac2Thanks @cody-dot-js! -markdownTwinsin development now gives only a twin that the build will prerender, when the framework knows those paths. React Router knows them from itsprerenderconfig. TanStack Start does not, so each twin route that answers markdown still counts.Breaking:
SetupInfo.prerenderedPathsmoves into eachmodemember of the union. In development, it isundefinedwhen the framework finds the paths only during the build. A plugin that reads it checksmodefirst.#17
2b0f699Thanks @cody-dot-js! - TheappServerVite plugin now writes a gzip copy (.<file>.gz) next to each text file of 1 kibibyte (KiB) or more in the client build, except HTML. The copy is a dotfile, so it has no URL of its own.app-server servesends the copy to a client that accepts gzip, with itsContent-LengthandVary: Accept-Encoding. The server no longer gzips these files for each request. A script of 19 kilobytes (KB) answers about 70% more gzip requests each second on Node.js, and it sends fewer bytes, because the build uses the highest gzip level.appServer()now gives a list of Vite plugins. An app that puts it inpluginsneeds no change. Code that reads a hook from it, such asappServer(options).load, now reads it from the first item:appServer(options)[0]?.load.If you keep the
/assets/*files of old builds for open tabs, also keep their.<file>.gzcopies.#14
6f60119Thanks @cody-dot-js! - Add thehealth()plugin and theshutdownDelayMsoption ofcreateServer.health()answers/livezwith 200. It answers/readyzwith 200, and with 503 after a stop signal. WithshutdownDelayMs, the server serves requests for that time after the signal, so the load balancer removes it before the drain. The drain deadline starts after the delay.defaultPlugins()runshealth()first, so a probe skips the access log. Thehealthoption takes its options, such as areadycheck:Breaking change: with
defaultPlugins(), the server now answers/livezand/readyz, and an app route at those paths no longer runs. Remove the app's route, or sethealth: falseto keep it.Before:
After:
Or, to keep the app's route:
#22
0b536a0Thanks @cody-dot-js! - TheappServerVite plugin now also writes the gzip and brotli copies of each HTML page, andapp-server servesends them. The server no longer compresses a prerendered page for each request. On Node.js, a 392 kilobyte (KB) page answers about 21,000 requests each second withAccept-Encoding: gzip, br, up from about 2,200.When
runtimeEnvhas public values, it changes each page. So the server sends those pages from the file, andstreamingCompress()gzips them for each request, as before.A
beforeStaticplugin that changes the bytes of an HTML page must now tell the static stages. Without this, a client that accepts gzip or brotli gets the compressed copy, and the plugin must not change it.#21
b59239cThanks @cody-dot-js! -defaultPlugins()now puts each opt-in plugin in its correct place, so an app does not need to know the order. Use these options:health: on by default.falseturns it off.buildId: on by default, with the ID of the client build.falseturns it off.contentSecurityPolicy: the options ofcontentSecurityPolicy().env: the plugin fromruntimeEnv(schema).noindex:trueturns it on.These plugins see every response, prerendered pages included.
beforeStaticstays for the app's own plugins, such as an auth check. They go after the plugins of the options, so they get the CSP nonce, and their responses get the headers of those plugins.Breaking changes:
defaultPlugins(), each document and data response now getsX-Build-Idand thebuildmetric inServer-Timing.buildId()inbeforeStaticnext to the defaultbuildId. One would replace the headers of the other, so the value that the app gave would be lost. Move the plugin to its option.Before:
After:
Or remove
buildIdto send the ID of the client build.#13
1b30ac8Thanks @cody-dot-js! - Require Node.js 26.engines.nodeis now^26.0.0. Node.js 26 is the active LTS release.Breaking change: the package no longer supports Node.js 24. Upgrade to Node.js 26 before you upgrade this package.
#31
050dc76Thanks @cody-dot-js! - On Node.js and Vercel, the server writes each response itself, not through the writer of@hono/node-server. It runs the header hooks on a list of the headers that it writes, so it copies no response for them. In a one-process A/B on Node.js withohaat 64 connections, a small route, a prerendered page, a hashed asset, and a rendered 6 KB page with gzip each answer 13% to 17% more requests each second.Content-Length, in one socket write with the head. A gzip page whose body has ended goes out in one write too.unhandled errorline with the request ID. Such a step is a body that fails or does not match itsContent-Length, or a header value that Node.js rejects. Before, a body that failed before the head went out as a complete 200 with the bytes read so far.Content-Lengthcloses the connection, so no client and no cache keeps a cut response as complete. Before, a body longer than itsContent-Lengthwent out in full.Uint8Array, such as a string, gives a 500. The Fetch standard allows bytes only.Breaking change for a plugin: a header hook of
withHeaderHookchanges the head once, where the response leaves, after the code that each stage runs afternext(). So code afterawait next()no longer sees the headers that the hooks of later stages set, such as theCache-ControlofprivateDocuments(). Read or change the final headers in a hook:streamingCompress()also run the hooks on a view of the headers, to read theCache-Control, and forstreamingCompress()theContent-EncodingandContent-Range, that leave. So a hook runs two or three times for a response with gzip or a compressed copy, rendered pages included. Each run must give the same headers, and a hook must do nothing else, such as log, count, or store a value. ApolicyofcontentSecurityPolicy()and thepathsofbuildId()run in a hook too.requestContext(). A hook that throws gets the answer of the app'sonError, as a stage that throws does.fetchgets the response before the hooks. A header that it sets or deletes goes out, but it does not see the headers that the hooks set.accessLog({ attributes })gets what went out as its second argument: the status, the headers after the hooks, the bytes, and whether the body stopped early. Read a response header there, such asattributes: (c, sent) => ({ cache: sent.headers.get("Cache-Control") }), not fromc.res, which can lack the changes of the hooks.For a listener adapter: a listener that writes each response itself can give each request an
edgebinding, a newResponseEdge. The server keeps the headers after the hooks in the edge, and the access log takes its line from the report of the listener. See the README.#14
6f60119Thanks @cody-dot-js! - Give each prerendered file a defaultCache-Control, and add thecacheControloption ofprerendered(). Before, a file with no captured header had none, and each CDN used its own rules..datafile, getpublic, max-age=0, must-revalidate.robots.txtor an image, getspublic, max-age=3600, stale-while-revalidate=86400.A captured header and the
headersoption win over the default.Breaking change: a prerendered file with no captured
Cache-Controlnow gets a public value. If a prerendered page is behind auth, setCache-Control: privatein its auth plugin. To keep the old behavior, setprerendered({ cacheControl: () => undefined }).#9
da0bc54Thanks @cody-dot-js! - Add thetrailingSlash({ mode })plugin and thetrailingSlashoption ofdefaultPlugins. Each page now has one URL. A GET or HEAD for a page URL in the other form gets a 308 to the URL in the site's form. The redirect applies to rendered and prerendered pages, and it keeps the query string and the URL encoding of the path.never(the default):/x/and/x//go to/x.always:/xgoes to/x/. A file in the client build, such as/LICENSE, and a path whose last segment has a dot, such as/llms.txt, keep their form.preserve: no redirect.Breaking change: with
defaultPlugins(), a GET or HEAD for/x/now gets a 308 to/x. Before, it got a 200. SettrailingSlash: "preserve"to keep the old behavior.For TanStack Start, set the same mode in the router, else the redirects loop.
RouterTrailingSlashfrom@ngrok/app-server/tanstack-startis the router's mode, somode satisfies RouterTrailingSlashfails to type-check when the two modes differ.#14
6f60119Thanks @cody-dot-js! - Add theprivateDocuments()plugin as the last stage ofdefaultPlugins(). If a rendered HTML document or a framework data response, such as a React Router.datarequest, has noCache-Control, it getsprivate, no-cache. Then a shared cache never keeps a page or loader data for one user. Prerendered files and/assets/*files do not change.Breaking change: with
defaultPlugins(), a rendered page with noCache-Controlnow getsprivate, no-cache. Before, it got none. If a CDN may cache a page, set a value such aspublic, max-age=60in its loader or route headers. A resource route that sends per-user JSON must set its ownCache-Control. To remove the plugin, usedefaultPlugins().filter((plugin) => plugin.name !== "private-documents").#14
6f60119Thanks @cody-dot-js! - Add thesecurityHeaders()plugin todefaultPlugins(), and add the opt-incontentSecurityPolicy({ policy })plugin.securityHeaders()sets these headers on each response, prerendered pages included. If a response already has one of them, it keeps its own value.X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originCross-Origin-Opener-Policy: same-originStrict-Transport-Security: max-age=31536000, on an HTTPS request onlyBreaking change: with
defaultPlugins(), each response now gets these headers. Before, it got none.same-originbreaks a pop-up that useswindow.opener, such as some OAuth and payment pop-ups. To change a value, set it in the option.falseturns one header off, andsecurityHeaders: falseturns them all off.Content-Security-Policyhas no default. To stop other sites from putting a page in a frame, setframe-ancestors 'none'in it.contentSecurityPolicy({ policy })sets aContent-Security-Policyon each HTML document, prerendered pages included. Turn it on with thecontentSecurityPolicyoption ofdefaultPlugins().policygets the request's Hono context, so the policy can change by path. It also gets thescript-srcsource list that trusts the document's scripts:'nonce-…' 'strict-dynamic', with a new nonce for each request. Read the nonce fromrequestContext()?.cspNonce, fromappServerContextin React Router, or fromcontext.appServerin TanStack Start. Give it to the render.'self'and the hash of each of its inline scripts. TheappServerVite plugin records the hashes of each HTML file in the client build, so an app needs no code to read or hash its pages.#15
69e5ec2Thanks @cody-dot-js! - AddstaleBuildReactRouter()andstaleBuildTanStack()from the new@ngrok/app-server/cliententry. After a deploy, they move an open tab to the new build at the next navigation, and the page that the visitor reads never reloads.fetchandinstrumentationofstaleBuildReactRouter()toHydratedRouter, and export itsmiddlewarein the rootclientMiddleware. A navigation to a route with no loader also moves the tab. The document load keeps the URL fragment, and a replace navigation stays a replace.staleBuildTanStack(router)ingetRouter(), on the client only. A navigation of a tab that knows of a new build clears the router cache, so the old code never renders the data of a preload.navigate(), a form, a history step, andblocker.proceed()each act as the router acts.<Link prefetch="intent">hover, can show the new build before the click.onNewBuild(served)runs once when the tab first sees another build, for the app's RUM tool.They send no request of their own and add no wait, so a client navigation makes the same requests as before.
defaultPlugins()runsbuildId(), so the server needs no code.buildId()now also sends the build asServer-Timing: build;desc="<value>"on each document and data response, so the client can read the build with no request.Add the
pathsoption ofbuildId(). The responses of those request paths, such as the app's API proxy routes, also carry the build inServer-Timing. Each query and mutation through them then tells an open tab about a new deploy, with no extra request. In a TanStack navigation, such a response from a new build loads the page as a document. A response that a cache can use again without the server gets no build.buildId()removes the build header and thebuildmetric of an upstream API from each response, so the client reads only this app's build.A header hook can now
deletea header, withresponse.delete(name). This adds a member toResponseHeaders, andbuildId()now removes theX-Build-Idheader of an upstream API from each response that is not the router's, so this release is a minor.#23
be87de4Thanks @cody-dot-js! - A build with theappServerVite plugin now runs with no install. Copy the build directory alone, and runnode build/server/app-server.js(dist/server/app-server.jsfor TanStack Start), or the same file withbunordeno run. The README section "Deploy" has a Dockerfile.ssr.noExternal: true, unless the app sets its ownssr.noExternalor setsssr.external: true.ssr.externalor in the bundler'sexternaloption, the whole package of an external subpath, each peer dependency of a package that the app keeps external, the peers of each peer, and each native package stay external. A package is native when a.nodefile is in it, or in a package of itsdependenciesoroptionalDependencies, such asoxc-parser. The build copies each external package, whole, with its installed dependencies, intonode_modulesin the server build. It copies each package inssr.external, also when no code imports it, such as a package that the app reads throughrequire.resolve.package.jsonwith"type": "module"into the server build.app-server.jsas its main module, the file starts the server, asapp-server servedoes. It setsNODE_ENVtoproductionwhenNODE_ENVhas no value.app-server serveworks as before.Breaking change: the server build is larger, and it no longer reads its dependencies from the app's
node_modules. Run the build on the platform of the server, because the copy holds only the native addon of the build machine. To keep a package out of the bundle, add it tossr.external. Before, a Dockerfile installed@ngrok/app-serverin the image and rannode node_modules/@ngrok/app-server/dist/cli.js serve build/server/app-server.js. After, it copiesbuildand runsnode build/server/app-server.js.#20
7128871Thanks @cody-dot-js! -appServer()needs no options. It finds the framework from the React Router or TanStack Start plugin in the Vite config, uses the node host, and usesserver.ts, elseserver.js, in the Vite root as the app. If the config has neither framework plugin or both, Vite stops with an error that says to passframework. An option that you set replaces its default, so a third-party adapter works as before.Before:
After:
Breaking change: if the app sets no
appand has aserver.tsorserver.jsin the Vite root, the server build now uses that file as the app. Before, the server used the default app. To keep the default app, move or rename the file.Patch Changes
#31
050dc76Thanks @cody-dot-js! - The access log costs less on Node.js and Vercel. The listener counts the body bytes as it gives them to the socket, so the access log no longer puts a second stream around each body or copies the response. On Bun and Deno, the server counts the bytes with one stream around the body, as before.The line changes in these ways:
http.status_codeis the status that went out, such as the 500 for a response that could not go out.durationends when the response closes, after the last bytes go to the socket. For a slow client, it now includes the time to send the body.http.client_abortedis also set when the client leaves before Node.js sends the last bytes, and when a body fails or ends before itsContent-Lengthafter the head.#16
e3bbc66Thanks @cody-dot-js! - Fix a crash, a file leak, and a build exposure, and make each request cheaper.next(), crashed the process at garbage collection. The node host now opens a file at the first read, and the gzip stream, the access log, and theruntimeEnv()script injection read only when the client reads.Content-Length, even when the file grows after the lookup.nodeFiles().stat()throws a file system error that does not mean a missing file, such asEACCESorELOOP, so the request gets a logged 500, not a silent miss.nodeFiles().open()throws for a file that it cannot read, so the request gets a logged 500, not a 200 whose body fails.streamingCompress()sends a body whoseContent-Lengthis under 1,024 bytes as it is. Gzip made such a body larger. It addsAccept-EncodingtoVaryonce, and only on a type that it can gzip, so an image no longer splits a CDN cache.etagPartvalues once, not for each static file request.appServerVite plugin fails the build when the server build is inside the client build. Development does not check, because it serves no build directory. Before, the static stages could serve the server's code, or the whole file system when both builds used one directory.appServerVite plugin records the captured headers of a prerendered path with a non-ASCII character, such as/café.json, under the decoded path that the server looks up.onNewBuildthrows: it reports the error. It reloads a document that the back/forward cache shows again after a move to a new build, and it does nothing on the server.app-serveraccepts-pfor--portand--help. A bad argument writes a message and the usage, not a stack trace.#17
2b0f699Thanks @cody-dot-js! - In production, the server keeps each static file up to 256 kibibytes (KiB) in memory after the first read, up to 32 mebibytes (MiB) in all. A later request or byte range needs no file read. A larger file streams from disk as before. On Node.js,/robots.txtanswers about 50% more requests each second, and a small hashed asset about 55% more.#21
b59239cThanks @cody-dot-js! -buildId()needs novaluenow. After the client build, theappServerVite plugin hashes the name and the content of each file of the client bundle. After the server build, it adds each file that the server build wrote to the client's assets directory, such as the React Router route manifest. It writes the hash asbuildIdin.app-server.json. With novalue,buildId()sends that ID.public/file, or a prerendered page keeps it, so an open tab does not move for nothing.value, such as the image tag, wins. With an unset or empty variable, such asvalue: process.env.IMAGE_TAG, the plugin sends the build's ID.buildId()with novaluesends no build, because Vite's HMR updates an open tab after each edit.Build the app again, so
.app-server.jsonhas the build ID.#31
050dc76Thanks @cody-dot-js! - Each access log line costs a fifth to a third less CPU time, with the same bytes in each line. The access log no longer parses a request URL that has no query, and it copies the line's attributes once, not three times.jsonSink()keeps the time string of the current second and millisecond, becausetoISOString()took 0.45 µs to 0.67 µs of each line. On Node.js, a line for a small route takes about 1.1 µs, not 1.6 µs, and a line for a browser page about 1.5 µs, not 2.3 µs. Each request's logger is also about 15% cheaper to make.#14
6f60119Thanks @cody-dot-js! - Add thetrustedProxyHopsoption ofcreateServer. Set it to the number of proxies that add toX-Forwarded-For, such as1behind the ngrok edge. Then the access log andrequestContext()?.clientAddressgive the client's address, not the proxy's. For a build with noserver.ts, setTRUSTED_PROXY_HOPS.#25
2e49fceThanks @cody-dot-js! -app-server startnow sends thecontentSecurityPolicyfor an HTML file in Vite's public directory, such aspublic/offline.html, with the hashes of its inline scripts. Before, the file got no policy in development, so a script that production blocks ran.devServerStagetakes the newpublicFilesoption for this.#14
6f60119Thanks @cody-dot-js! - In development, a response that Vite answers, such as a source module or apublic/file, now gets the headers ofsecurityHeaders(),noindex(), andbuildId(). Before, these responses did not get them, so development and production sent different headers.Add
withHeaderHook(c, next, hook). An app plugin that sets a header uses it to get the same behavior in development. As in production, the hook of an earlier plugin runs last.#31
050dc76Thanks @cody-dot-js! - WithtrustForwardedProto, a request on the node host's listener on Node.js no longer gets copied to take its scheme fromX-Forwarded-Proto, as on Vercel. The listener builds the request with the forwarded scheme. In a one-process A/B on Node.js withohaat 64 connections, withX-Forwarded-Proto: httpson each request as a proxy that ends TLS sends it, routes answer 24% to 39% more requests each second.For a listener adapter:
Listener.starttakes an optional second argument,{ trustForwardedProto }. A listener that builds each request itself can take the scheme fromX-Forwarded-Protothen. A listener that ignores it, and a caller that gives no second argument, work as before.#24
a8ae3eaThanks @cody-dot-js! - Fix a 500 withtrustForwardedProtoon the node host. A request with anX-Forwarded-Protothat differs from the socket's scheme, such ashttpsbehind a proxy that ends TLS, now gets the trusted scheme, also with a body. A TRACE request keeps its method.#31
050dc76Thanks @cody-dot-js! - On Node.js, a gzip response takes one socket write less when its body ends by the time the gzip stream finishes the last chunk: that chunk and the gzip trailer go out as one. In a one-process A/B on Node.js with a stub renderer, a page of 6 KB before gzip that the renderer queues at once answers about 5% more requests each second, and a page that streams its shell first about 3% more. That is about 3 µs less server time for each response, so a page with a slower render gains a smaller share.#31
050dc76Thanks @cody-dot-js! - On Node.js, a gzip response whose body ends with its first chunk, up to 64 KiB, such as most rendered pages and data responses, uses a zlib stream from a pool. A new zlib stream cost more main-thread time than zlib took to compress a small page. In a one-process A/B withohaat 64 connections, a rendered 6 KB page with gzip answers about 16% more requests each second.The pool keeps up to 16 zlib streams between responses, about 4.3 MiB in all. The gzip bytes of a body depend on that body alone, so a response cannot learn a byte of another response from its own size:
#22
0b536a0Thanks @cody-dot-js! - A static file now keeps its compressed copy back when abeforeStaticplugin setsCache-Control: no-transformin its header hook. Before, the static stages read only their own headers, so the client got the copy withno-transform. The static stages now run each hook on a copy of the headers first, so a hook can run more than once for one request.#29
182eca7Thanks @cody-dot-js! -streamingCompress()now gzips the chunks that a body has queued together as one chunk, up to 16 kibibytes (KiB). Each gzip flush costs more than its bytes, and React queues many small chunks at once. With gzip, a page that renders on each request now gets about 20% to 30% more requests per second on Node.js, and about 10% to 15% more on Bun and Deno. A chunk that comes later still goes out at once.#31
050dc76Thanks @cody-dot-js! - Each request passes the plugins with less work. A plugin step no longer wraps each call in one more async function, and a header hook joins one list for each request, not a new copy of the list. In a one-process A/B on Node.js withohaat 64 connections, routes answer 2% to 5% more requests each second.#18
a2d8ac2Thanks @cody-dot-js! -markdownTwinschanges:Link: </docs/button.md>; rel="alternate"; type="text/markdown", so an agent that does not ask for markdown can find the twin.markdownTwinsnow works in development. A request that prefers markdown gets the framework's answer for the twin's URL, so an edit to the twin's route shows at the next request. If that answer is an error or is not markdown, the request gets the HTML.prefersMarkdownreturns at once when theAcceptheader does not name markdown. This cuts the time of a browser request for a page with a twin by about 20%.#14
6f60119Thanks @cody-dot-js! - SendCache-Control: no-storewith the 404 for a missing/assets/*file, so a CDN does not keep the miss after a deploy. The new "Deploys and open tabs" README section says to keep old assets for 7 days, so an open tab keeps working.#17
2b0f699Thanks @cody-dot-js! - On Node.js, afetch()result in a route is now aninstanceof Response. The listener no longer replaces the globalResponse, inserveand instart. This matches Bun and Deno, and small responses are faster. The listener still replaces the globalRequest, sonew Request(c.req.raw)copies a request.#30
d93f445Thanks @cody-dot-js! - Each request costs less time on Node.js.jsonSink()writes the lines of one event-loop turn to stdout in one write, at the end of the turn. Before, each line was its own write. Each line still reaches stdout before the process exits, and before the vercel handler returns. The line format does not change. AjsonSink(write)with its ownwritestill gets one call for each line.requestId()sets its header once for each request. Before, a response fromcontext.text()orcontext.json()also got a copy of the header before the route ran. The header, its checks, and the logged ID do not change.#14
6f60119Thanks @cody-dot-js! - Add thenoindex()plugin. It setsX-Robots-Tag: noindex, nofollowon each response, prerendered pages included, so a search engine does not index a preview deploy. Turn it on with thenoindexoption ofdefaultPlugins():#17
2b0f699Thanks @cody-dot-js! - The default plugins copy a response at most once to change its headers, not once for each plugin. A small route answers about 10% more requests each second on Node.js. A response object that the app sends again stays unchanged.#25
2e49fceThanks @cody-dot-js! - The server build now works with a package that reads a file next to its own module, such as@vercel/ogwithresvg.wasm. Such a package stays external, and the build copies it. The build also copies each package that the app's code gives torequire.resolve()orimport.meta.resolve()as a string. Before, the app had to add each of these packages tossr.external, else the server failed when it read the file.#25
2e49fceThanks @cody-dot-js! - ARegExpor a function in the bundler'sexternaloption now keeps the peers of the packages it matches external. The build tests it on the name of each package in the app's dependencies. Before, the external package and the bundle could each get their own copy of a peer, such asreact, or the server could not find the peer at run time.#10
cded0f9Thanks @cody-dot-js! - AddprefersMarkdown(request). It tells if a request asks for markdown before HTML, per acceptmarkdown.com. Only an explicittext/markdownrange selects markdown, and itsqmust be equal to or more than theqof HTML. A wildcard never selects markdown. Use it in a plugin,entry.server, or a route, so each place answers one request the same way.Add the
markdownTwinsoption ofprerendered(). A request that prefers markdown gets the page's prerendered twin, such as/docs/button.mdfor/docs/button. Each answer for a page with a twin getsVary: Accept, and a page with no twin gets none.twinOfchanges the mapping.#14
6f60119Thanks @cody-dot-js! - Add theskipoption ofprerendered(). A path thatskipmatches goes to the app with no file lookup, so the app's auth check runs. A prerendered file answers before the app's routes, so a private page needsskipor an auth check inbeforeStatic. The new "Protect pages" README section shows both.#14
6f60119Thanks @cody-dot-js! - Add theredirects(rules)plugin and theredirectsoption ofdefaultPlugins. Each rule sends a GET or HEAD for an old path to a new URL with a 301 or a 308, and keeps the query string. A rule matches an exact path,:namefor one segment, and a last*for the rest. The first rule that matches wins. The table runs beforetrailingSlashand follows its mode, so an old URL gets one redirect. A rule that is not valid stops startup. The "Redirects" README section has the full rules.#17
2b0f699Thanks @cody-dot-js! -streamingCompress()no longer gzips a body from a string under 1 kibibyte (KiB), such asc.text("ok"). Such a body has noContent-Length, so the plugin gzipped it, and gzip made it larger. A small route answers about 55% more gzip requests each second on Node.js. A streamed page still flushes its first chunk at once.#17
2b0f699Thanks @cody-dot-js! - The node host reads a static file of up to 64 kibibytes (KiB) with one read. A static file that is not in memory, such as/robots.txt, answers about 10% more requests each second on Node.js.#17
2b0f699Thanks @cody-dot-js! - In production, the server lists the client build once at start, and the static stages find each file in that list. A request no longer callsstaton the disk, also when no file matches it. A small route answers about 30% more requests each second on Node.js.The server does not see a file that you add to the client build while it runs. A file that you remove while it runs gets a 500, unless the server keeps it in memory. Restart the server after you change the build. If the walk gets an error that does not mean a missing file, such as
EACCESorELOOP, the server does not start, and the error names the path. A host adapter can give the same list through the new optionalStaticFiles.snapshot().#14
6f60119Thanks @cody-dot-js! - Logtrace_idandspan_idfrom a valid W3Ctraceparentheader on each log line of the request. WithrequestId({ datadogTraceIds: true }), each line also getsdd.trace_idanddd.span_id, so Datadog connects the line to its trace.Add
accessLog({ attributes })to put the app's own attributes on each request line. AddaccessLog({ serverTiming: true })to setServer-Timingon each HTML document.#24
a8ae3eaThanks @cody-dot-js! - Add thevercelhost. It runs the pipeline in one Node.js Vercel Function, and Vercel's CDN serves the hashed files.After the build,
vercel()writes.vercel/output. Deploy it withvercel deploy --prebuilt./assets/with a one-year immutable cache. ThecdnPathsoption adds more prefixes, andcdnHeadersadds headers to each CDN file, such as CORS headers.X-Forwarded-Proto, and the client address comes fromX-Real-IP, because Vercel sets both headers.regions,memory, andmaxDurationoptions set the function's Vercel config.For a host adapter:
appServerexport of a build has afetch(request, bindings)for a serverless host. It answers one request and does not listen. The first call runs each plugin'ssetup. A failedsetuplogssetup failed, and the next call runs eachsetupagain.hostModule, the host's run-side module, from the bundle.HostRuntime.listenersis optional.start()throws when the app and the host have no listener.HostBuildhas an optionaloutputthat writes the host's deploy output after the build.#31
050dc76Thanks @cody-dot-js! - On Vercel, a request no longer gets copied to take its scheme fromX-Forwarded-Proto. The handler gives@hono/node-serverthe request target with the forwarded scheme, so the request gets itshttps:URL when it is made. Each copy had copied every request header two times. In a one-process A/B of the handler with Vercel's proxy headers andohaat 64 connections, routes answer 30% to 41% more requests each second.