Skip to content

feat: serve static assets from the build manifest - #16908

Open
Nic-Polumeyv wants to merge 5 commits into
version-3from
manifest-static-assets
Open

feat: serve static assets from the build manifest#16908
Nic-Polumeyv wants to merge 5 commits into
version-3from
manifest-static-assets

Conversation

@Nic-Polumeyv

@Nic-Polumeyv Nic-Polumeyv commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Closes #16565, fixes #11766.

Serving static assets from build manifest is 10 to 20% faster than sirv on a local loopback benchmark, winning all eight comparisons across two interleaved rounds of plain, brotli, icon, and extensionless-alias requests at 20 concurrent keep-alive connections.

Currently, the static file server re-derives at request time what the build already computed. adapter-node now writes two tables into the emitted manifest, one per mount, mapping every servable pathname (including precomputed foo.html/foo/index.html aliases in sirv's resolution order) to its file, size, content-hash ETag and compressed-variant sizes/hashes. Serving (src/static.js) is a map lookup and a stream, Vary is sent exactly when a variant exists (which retires uncompressed_extensions and the over-send #16566 had to work around), and the range handling is RFC 7233 (bytes=0-0 returned the whole file under sirv, bytes=-3 was off by one — the former is the probe HTML5 video and PDF.js use). sirv, @polka/url and their transitive mrmime/totalist drop out of the adapter, along with the 27kB sirv chunk in the build output.

manifest.mimeTypes is now also seeded from client output extensions (same mechanism as the prerendered seeding from #16564), since .js/.css/imported-asset types previously came from sirv's bundled mrmime.

Deliberate behavior changes: ETags are content hashes instead of mtime-derived (stable across rebuilds of identical files), Last-Modified is no longer sent (deploy processes reset mtimes, and the ETag supersedes it), Accept-Ranges is always advertised, and files with unknown extensions omit Content-Type instead of sending an empty one. Base-path key construction follows the same ${base} composition the old asset_dir used, but no adapter-node test app exercises a base path - that's a pre-existing coverage hole.

Sits on #16907; only the last commit is new.

Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests

  • Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

Changesets

  • If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running pnpm changeset and following the prompts. Changesets that add features should be minor and those that fix bugs should be patch. Please prefix changeset messages with feat:, fix:, or chore:.

@pkg-svelte-dev

pkg-svelte-dev Bot commented Aug 22, 2026

Copy link
Copy Markdown

Install the latest version of @sveltejs/kit from ccb4bc9:

pnpm add https://pkg.svelte.dev/@sveltejs/kit/c/ccb4bc970fd47aa4fee43e1cf4363f282e62ac05

Open in pkg.svelte.dev: https://pkg.svelte.dev/repos/kit/pr/16908

@changeset-bot

changeset-bot Bot commented Aug 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ccb4bc9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@sveltejs/adapter-node Minor
@sveltejs/kit Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@svelte-docs-bot

Copy link
Copy Markdown

@Nic-Polumeyv
Nic-Polumeyv force-pushed the manifest-static-assets branch 3 times, most recently from 81ea893 to 1192a76 Compare August 22, 2026 19:31
@Nic-Polumeyv
Nic-Polumeyv marked this pull request as ready for review August 22, 2026 19:50
Comment thread packages/adapter-node/src/static.js Outdated
@elliott-with-the-longest-name-on-github

Copy link
Copy Markdown
Contributor

I'm going to merge the downstack PR of this one because it's straightforward and gets rid of a dep, but this one needs some additional discussion. It makes some tradeoffs that might be worth it, but I don't think are straightforward enough to just yolo into without the broader team's input. We can talk about this at the maintainer's meeting on Friday. Stuff I see while going through (some of these are existing sirv issues that we'd inherit here):

Generated static output is now immutable

Currently, sirv scans the filesystem when the server starts. This PR discovers files and computes their sizes, ETags, and compressed variants when the adapter runs, which only happens at buildtime. This means that changes between vite build and server startup are no longer recognized.

If a deployment adds a file to the build output, the new file is never served. Deleting or replacing a file has similar unpredictable consequences.

This is relevant to (off the top of my head):

  • Runtime configuration injected into static JSON
  • Docker image startup scripts that modify build output
  • Generated assets copied during deployment
  • Custom servers that manage build/client themselves

This isn't necessarily an unacceptable model, but it's a major compatibility break from what we currently have. A compromise would be to manifest the known path set but obtain size and validator metadata at startup... but I'm not sure how much better that would really be.

Accept-Encoding negotiation is incorrect

In packages/adapter-node/src/static.js:104-113:

The implementation detects token substrings rather than parsing quality values. It sends encodings clients explicitly reject:

Accept-Encoding: br;q=0
Accept-Encoding: gzip;q=0
Accept-Encoding: gzip;q=1, br;q=0

The last case receives Brotli even though the client prefers gzip and refuses Brotli.

It also mishandles:

  • Relative quality values.
  • Uppercase coding names.
  • Wildcards.
  • identity;q=0.
  • Some malformed parameters.

sirv is also simplistic here, so not all of this is a regression. However, replacing sirv with first-party code means SvelteKit becomes responsible for this behavior. Shipping a known-invalid implementation is hard to justify.

Static files are served for methods other than GET and HEAD

References:

  • packages/adapter-node/src/static.js:80-95
  • packages/adapter-node/src/static.js:167-177

A matching asset is served for:

POST /asset.txt
PUT /asset.txt
DELETE /asset.txt
OPTIONS /asset.txt

This can shadow a +server.js route sharing a pathname with an asset. It also means an unread request body may remain on a keep-alive connection.

sirv has the same behavior, so this is not a PR regression. But it should be fixed while taking ownership of the static server.

Conditional requests remain incomplete and lose Last-Modified

Reference: packages/adapter-node/src/static.js:115-118

Only exact If-None-Match equality works. These valid forms do not:

If-None-Match: *
If-None-Match: "old", "current"
If-None-Match: W/"current"

The PR also removes Last-Modified, so If-Modified-Since no longer works.

Strong content ETags are an improvement over sirv's weak size/mtime validators, and dropping Last-Modified can be defensible. But this changes browser and CDN revalidation behavior and should be intentional.

Additionally, the generated 304 response omits validator and Vary headers that caches may need to update the stored response.

Range handling is incomplete

Reference: packages/adapter-node/src/static.js:139-165

The new suffix and open-ended range handling is better than sirv, but:

  • If-Range is ignored.
  • A stale If-Range validator still gets a partial response.
  • 416 responses omit representation metadata.
  • Multiple ranges are silently ignored rather than handled or explicitly rejected.
  • Ranges can apply to a compressed representation, which is valid but surprising for media and PDF clients.
  • Some malformed range behavior is incidental rather than deliberate.

URL decoding behavior changes

References:

  • packages/adapter-node/src/static.js:13-31
  • packages/adapter-node/src/static.js:81-95

The replacement decodes the whole pathname with decodeURIComponent; sirv uses decodeURI.

That changes reserved-character handling:

/foo%2Fbar.txt

can now match:

/foo/bar.txt

Encoded ?, #, and other reserved characters also behave differently.

The closed manifest means this does not appear to create directory traversal. It does change which URLs alias the same asset and may interact unexpectedly with custom-server middleware that rewrites req.url.

Scaling?

References:

  • packages/adapter-node/index.js:36-74
  • packages/adapter-node/index.js:211-281

Every asset is read and hashed. With precompression enabled, each original and compressed representation is hashed separately.

For each compressible file, adaptation approximately does:

  1. Copy the original.
  2. Read it for gzip.
  3. Read it for Brotli.
  4. Read it again for its hash.
  5. Read the gzip output for its hash.
  6. Read the Brotli output for its hash.

The implementation uses broad Promise.all concurrency. Large asset sets could hit:

  • File descriptor limits.
  • High temporary memory use.
  • Increased build times.
  • Larger generated JavaScript and source maps.
  • Longer startup parsing.
  • Higher resident memory due to both serialized entries and runtime Maps.

This doesn't matter much at smallish sizes, but at Vercel I've seen projects with many thousands of files... I would want to know how the performance scales.

@Nic-Polumeyv

Nic-Polumeyv commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks!

On the HTTP semantics, everything you listed reproduces on this build and I'll push fixes: q-value parsing for Accept-Encoding, a GET/HEAD gate (sirv is the only static server I checked that lacks one; send, h3, nitro, @fastify/static and nginx all have it), ETag and Vary on the 304 per RFC 9110 §15.4.5, list/*/W/ handling for If-None-Match, If-Range, and decoding via the same decodeURI-with-%25-preserved rule kit's router uses in decode_pathname, which is also what vite dev and sirv do.

I took a look at sirv's source code. sirv sends Last-Modified but never evaluates If-Modified-Since (build.mjs:185 only checks If-None-Match), so dropping it doesn't break any revalidation that worked before. What it does change is that browsers and CDNs can no longer guess a reuse window from the mtime for files outside _app/immutable (nothing sets Cache-Control on those), so they revalidate with If-None-Match and get a 304 instead.I'd take that over users keeping a stale favicon after a deploy because the browser guessed a reuse window from the old file's mtime. And the multi-range 200 is the "A server MAY ignore the Range header field" branch of RFC 9110 §14.2, with the 416 already carrying Content-Range: bytes */size.

I measured on this branch vs the sirv build, N × 3 KB compressible files plus 3 × 5 MB binaries, Node 22, loopback, five cold starts each:

N=10 000 this PR sirv
vite build wall 23.3 s 20.6 s
build peak RSS 4.60 GB 4.64 GB
manifest.js 2.42 MB 170 KB
handler chunk / source maps 3.0 MB / 4.4 MB 455 KB / 815 KB
time to listening 324 to 361 ms 536 to 689 ms
server RSS after 200 requests 127 MB 137 MB

At N=1000 the build difference is 0.8 s and everything else is within noise except the manifest (246 KB vs 18 KB). Startup and resident memory go the other way from what you expected because sirv's totalist scan plus a stat per file costs more than parsing the manifest.

With ulimit -n 1024 both builds die on the same EMFILE inside builder.compress, which already opens four unbounded streams per file; the hash pass runs after it with fewer fds in flight. You're right about the read count. I can bring it down to one extra read per file by taking variant sizes from stat and deriving the .gz/.br ETags from the original's hash. Although, that doesn't improve the ~225 bytes per file in the manifest, and I don't have a good number for how many files a large Vercel project ships. If you do, that decides whether the table needs to move out of the JS bundle.

On the freeze itself, sirv already freezes at boot: totalist caches stats, so a file replaced after startup is served with the old Content-Length. Manifesting the paths and stat-ing at boot doesn't get your Docker cases back either, since a closed path set still never serves an added file. I think we should list the output dir at startup, the way sirv does now, keep the build-time hash and variants for every file whose size and mtime still match the manifest, and rehash or add any file that doesn't. Boot cost is what sirv pays today and the content ETags survive for everything the deploy didn't touch. I'll push that.

@Nic-Polumeyv
Nic-Polumeyv force-pushed the manifest-static-assets branch from 68a047e to f6397a4 Compare August 26, 2026 01:25
@Nic-Polumeyv

Nic-Polumeyv commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

With the aliases built at boot and the variants storing only a size, the manifest is ~150 bytes per file (1.5 MB at 10k files, down from 2.4 MB). Time to listening at 10k files is 510 to 652 ms against sirv's 536 to 689, with the walk being the same readdir sirv's totalist does. HUGE

A file overwritten after the build is served with its new content, uncompressed, because the .gz/.br next to it are from the old one; sirv served the old .gz to clients accepting gzip. Unchanged files are recognised by size and mtime, so a deploy that resets mtimes rehashes everything once at boot.

Base automatically changed from remove-polka to version-3 August 26, 2026 06:31
@teemingc
teemingc force-pushed the manifest-static-assets branch from f6397a4 to b76f6b9 Compare August 26, 2026 06:31
@elliott-with-the-longest-name-on-github

Copy link
Copy Markdown
Contributor

On the freeze itself, sirv already freezes at boot

Right, but freezing at boot and freezing at buildtime are very different things -- freezing at boot still allows you to, for example, build the same app once but swap out a JSON configuration file and deploy it multiple places.

@svelte-triage-bot

Copy link
Copy Markdown
Contributor

Exactly. The implementation should not treat the build manifest as an immutable allowlist: it needs to validate recorded files and discover additions at startup, so deployment-time replacement or addition of static configuration files remains supported.

@Nic-Polumeyv

Copy link
Copy Markdown
Contributor Author

freezing at boot and freezing at buildtime are very different things

Since d554932, recorded files are stat-ed at startup and rehashed on a size or mtime change, added files under the client dir are discovered, deleted ones dropped.

@Rich-Harris

Copy link
Copy Markdown
Member

I haven't looked too closely at the code because unfortunately it needs a rebase — the recent adapter API changes (dropping generateManifest) conflict with this branch.

Having said that: are we sure we want to do boot time rather than build time? When would you 'swap out a JSON configuration file' in your static directory? That would be a very messed up place to put it. Surely you'd just use env vars? If we do it at boot time don't we need to ship a mime type lookup that's unnecessary if we generate the lookup at build time?

@Nic-Polumeyv

Nic-Polumeyv commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

The mime table is manifest.mimeTypes, which kit already ships and fetch.js already uses, so neither option adds a lookup. I lean build-time too, every other adapter already serves exactly what the build produced. Dropping ccb4bc9 gets the build-time version back.

Key the prerendered table by the exact paths kit prerendered, which removes
the prerendered set and the request-time gate that filtered out unreachable
alias keys. The manifest now ships one string per prerendered page instead
of four. Fold create_asset_map into serve_static and resolve content types
once at boot instead of per request.
An unhandled read stream error (a file deleted from the build output,
EMFILE) crashed the process. Headers are already sent by then, so drop
the connection.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

serve static assets from the build manifest adapter-node does not serve filenames that end with +

4 participants