Skip to content

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

Merged
Rich-Harris merged 15 commits into
version-3from
manifest-static-assets
Sep 23, 2026
Merged

Rich-Harris merged 15 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.

adapter-node serves client and prerendered files through sirv, which re-derives at request time what the build already computed: it walks the output directory at boot and stats every file, takes Content-Type from its own bundled mrmime (so types kit adds to the manifest never reach it), sets Vary from its options rather than from the file it resolved (the over-send #16566 had to work around), and gets byte ranges wrong (bytes=0-0, the probe HTML5 video and PDF.js use, returned the whole file; bytes=-3 was off by one).

The adapter now records two tables at adapt time, one per mount, exported from build/adapter-node.js alongside the other hand-off values. Each maps a servable pathname (including the foo.html/foo/index.html aliases in sirv's resolution order) to its file, size, content-hash ETag and compressed-variant sizes. src/static.js resolves both tables into one map at boot, so a request is a lookup, header negotiation and a stream. sirv, @polka/url and their transitive mrmime/totalist drop out of the adapter, along with the 27kB sirv chunk in the build output. builder.mimeTypes is now also seeded from client output extensions, the mechanism #16564 added for prerendered output.

The breaking changes (build-time asset list, content-hash ETags without Last-Modified, GET/HEAD only) each have a changeset. Assets are frozen at build time rather than boot time because every other adapter serves exactly what the build produced.

Percent-decoding follows kit's router (decodeURI with %25 preserved), Accept-Encoding is parsed with q-values, If-None-Match accepts lists, * and weak tags, and If-Range is honoured. static.spec.ts covers each behaviour on its own. No adapter-node test app exercises a base path, a pre-existing coverage hole.

Adapt-time measurements: #16908 (comment)

@pkg-svelte-dev

pkg-svelte-dev Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

Install the latest version of @sveltejs/kit from 9e67c1f:

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

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: 9e67c1f

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 Major
@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.

@coderabbitai

coderabbitai Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

📝 Walkthrough

Walkthrough

adapter-node now records client and prerendered asset metadata during adaptation and serves those assets through manifest-backed middleware. The middleware supports MIME types, aliases, compression negotiation, content-hash ETags, conditional responses, byte ranges, redirects, and streaming. The Node handler no longer uses sirv for static assets. Kit now records MIME extensions from client output files. Tests and documentation cover the new behavior.

Assessment against linked issues

Objective Addressed Explanation
Serve static assets from the build manifest, including recorded MIME types, compression metadata, content hashes, and exact paths [#16565] ✅
Serve filenames ending in + in adapter-node production output [#11766] ✅

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: 🔵 Low · up to 7b933

Clients that reject all available encodings may receive an unacceptable response instead of 406. The issue is bounded and straightforward to fix.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: serving static assets from the build manifest.
Linked Issues check ✅ Passed The changes address both linked issues. The adapter uses manifest asset tables with hashes, aliases, compressed variants, and MIME types. It removes the listed runtime dependencies and adds coverage f…
Out of Scope Changes check ✅ Passed The implementation, type updates, dependency changes, MIME type propagation, test fixtures, integration tests, and changesets all support the linked objectives. No unrelated changes are evident.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 7 files. (7 skipped: 7…
Full details: Linked Issues check

Explanation

The changes address both linked issues. The adapter uses manifest asset tables with hashes, aliases, compressed variants, and MIME types. It removes the listed runtime dependencies and adds coverage for filenames ending in '+'.

Full details: Docstring Coverage

Explanation

Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 7 files. (7 skipped: 7 unsupported.)

Warning

Some tools did not complete. Review the errors below.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

packages/adapter-node/index.js

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

packages/adapter-node/internal.d.ts

ESLint skipped: the matched ESLint configuration already failed (missing-dependency).

packages/adapter-node/package.json

ESLint skipped: the matched ESLint configuration already failed (missing-dependency).

  • 5 others

Comment @coderabbitai help to get the list of available commands.

@Nic-Polumeyv

Nic-Polumeyv commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor Author

I removed the boot-time commit.

Additionally, hashing no longer goes through read streams. measure_files reads one file at a time into a buffer and hashes that, and compressed variants derive their ETag from the original instead of being hashed again. With #16993, 3000 files x 20 KB, warm cache:

before now
adapt time 3.9 s 3.1 s
peak memory +600 to 1300 MB +8 MB
open files at once all of them sixteen

@teemingc teemingc linked an issue Sep 7, 2026 that may be closed by this pull request
@Nic-Polumeyv
Nic-Polumeyv marked this pull request as ready for review September 15, 2026 00:14

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Advanced

Run ID: ddacbebd-082e-406f-a292-607c695e39b3

📥 Commits

Reviewing files that changed from the base of the PR and between adb5140 and 7b933e3.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (13)
  • .changeset/static-assets-build-time.md
  • .changeset/static-assets-etag.md
  • .changeset/static-assets-methods.md
  • documentation/docs/25-build-and-deploy/40-adapter-node.md
  • documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md
  • packages/adapter-node/index.js
  • packages/adapter-node/internal.d.ts
  • packages/adapter-node/package.json
  • packages/adapter-node/src/handler.js
  • packages/adapter-node/src/static.js
  • packages/adapter-node/src/static.spec.ts
  • packages/adapter-node/test/apps/basic/test/test.js
  • packages/kit/src/core/adapt/builder.js
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • sveltejs/svelte (manual)

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

}

/** @param {string} coding */
const weight = (coding) => weights.get(coding) ?? weights.get('*') ?? 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject requests that disallow every available representation.

For a normal GET, when identity;q=0 or *;q=0 rejects identity and all available compressed variants have weight zero, negotiate returns undefined. serve_static then selects the identity file and can send it with status 200. Return a distinct “not acceptable” result and send status 406 instead.

@Nic-Polumeyv
Nic-Polumeyv removed this pull request from stack #17110 September 17, 2026 19:30
@Nic-Polumeyv
Nic-Polumeyv changed the base branch from version-3 to compress-once September 17, 2026 19:32
@Nic-Polumeyv
Nic-Polumeyv added this pull request to stack #17126 September 17, 2026 19:32
Base automatically changed from compress-once to version-3 September 18, 2026 20:39
@teemingc
teemingc force-pushed the manifest-static-assets branch from 28d211a to 3b68018 Compare September 18, 2026 20:39
@Nic-Polumeyv
Nic-Polumeyv removed this pull request from stack #17126 September 19, 2026 13:29

Development dependencies will be bundled into your app using [Rolldown](https://rolldown.rs/). To control whether a given package is bundled or externalised, place it in `devDependencies` or `dependencies` respectively in your `package.json`.

Client assets and prerendered output are served from a list of files recorded during the build. Only `GET` and `HEAD` requests are served from it; other methods continue to SvelteKit. Every asset carries an ETag computed during the build, so conditional requests revalidate with an empty `304` response. Byte ranges are supported. Files below SvelteKit's `immutable` directory receive `Cache-Control: public,max-age=31536000,immutable`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should probably 405 for non GET/HEAD requests instead of continuing to the server — this is what generally happens on other platforms. If the request continues to the server then the likely best case is a 404, which would be incorrect

- the `ORIGIN` environment variable is removed (set `paths.origin` in your Vite config instead)
- static assets are served from a list recorded at build time; files added to the output directory afterwards are not served, and replaced ones keep their old size and `ETag` (use environment variables for runtime configuration)
- `ETag`s for static assets are content hashes, and `Last-Modified` is no longer sent
- only `GET` and `HEAD` requests are served static assets; other methods reach SvelteKit

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Rich-Harris Rich-Harris left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fantastic!

@Rich-Harris
Rich-Harris merged commit 68791be into version-3 Sep 23, 2026
40 checks passed
@Rich-Harris
Rich-Harris deleted the manifest-static-assets branch September 23, 2026 14:35
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 + EMFILE: too many open files

4 participants