Skip to content

feat: make dev server compatible with FetchableDevEnvironment#15574

Draft
teemingc wants to merge 243 commits into
version-3from
fetchable-dev-environment
Draft

feat: make dev server compatible with FetchableDevEnvironment#15574
teemingc wants to merge 243 commits into
version-3from
fetchable-dev-environment

Conversation

@teemingc

@teemingc teemingc commented Mar 20, 2026

Copy link
Copy Markdown
Member

This PR changes the dev, preview, build analysis and prerender to run inside of the configured Vite SSR environment. This required the following fundamental changes:

1. Avoiding Node.js imports in the runtime

  • Some of our utilities had to be moved to a separate file to avoid importing Node.js modules. Others had to be recreated to avoid adding another dependency if we want the same functionality in a Node-agnostic environment.
  • node:fs is a requirement but we import it in the main process instead and communicate the results back to the Vite SSR environment.
  • We can't use AsyncLocalStorage.enterWith because it's a Node.js-only experimental API

2. Replacing Vite's ssrLoadModule

The Vite docs recommendation is to create a ModuleRunner or the RunnableDevEnvironment instance to achieve a similar functionality but these don't work with Cloudflare's environment. There's also no strict contract for environments to make these available to us so they can't be relied on. We solve this in two different ways:

  • We use Vite's import.meta.hot.on in the SSR environment to listen for events from the main process, compute the result, and send it back.
  • Instead of importing the Server class from the build output in the main process, we spin up a Vite development server with the build output but proxy the Server class by intercepting module resolution with Vite's resolveId hook. Starting a dev server and sending a request or HMR event seems to be the only way to run a module in the environment.

3. Communication between the main process (where Vite runs) and the SSR environment (where user code runs)

The two points before this makes this a requirement. Now we'll detail the different types of communication that exist as a result:

One way communication

  • Main process to SSR environment. e.g., when we detect a server asset import from a Vite hook, we can update the server manifest in the environment using environments.ssr.hot.send. This helps us retain synchronous access to the filesystem from a non-Node environment such as checking if a filename exists as a key in the server assets map. We can also construct virtual modules with serialised data that the can be imported and accessed in the environment.
  • SSR environment to main process. e.g., when an error occurs on the server, we use import.meta.hot.send so that the main process can then create a Vite error overlay in the browser. This replaces our loudSsrLoadModule utility.

Two way communication

  • Main process to SSR environment and back to the main process. Previously we could just run ssrLoadModule to run some code in Vite's pipeline and get a result back. Now, we have to ensure the SSR environment has an import.meta.hot.on event listener attached, emit an event, compute in the environment, and receive the results back in the main process through environments.ssr.hot.on and Promise.withResolvers to await the result. This is used for retrieving remote function info, etc. Alternatively, we can also proxy the Server class during analysis and prerendering to respond with our environment computed result as mentioned earlier.
  • SSR environment to main process and back to the environment. This requires the operation to be asynchronous if it wasn't already. We also can't re-use the two-way import.meta.hot approach above because Cloudflare's workerd doesn't like responding to requests from a context created by import.meta.hot.on. Therefore, we use fetch to send a request to the running Vite dev server, configure the Vite dev server using the configureServer hook to intercept the request via vite.middlewares.use, and respond with the computed result. This is primarily used for getting CSS to inline to avoid FOUC during dev, finding out which param matchers exist from the filesystem, or even checking if a feature should be allowed by the adapter.supports function which we can't serialise.

Most of the import.meta.hot and fetch style communication requires serialising and deserialising data using devalue.

Future PRs

  • adapter-static environment that uses sirv on the build output instead of running the SSR server
  • adapter-node environment that allows using a custom entry point similar to after building
  • adapter-netlify environment to run things in serverless/edge mode?

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:.

Edits

  • Please ensure that 'Allow edits from maintainers' is checked. PRs without this option may be closed.

@changeset-bot

changeset-bot Bot commented Mar 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 987889c

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

This PR includes changesets to release 1 package
Name Type
@sveltejs/kit Major

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

@teemingc teemingc changed the title feat: fetchable dev environment feat: fetchable dev environments Mar 20, 2026
@teemingc
teemingc changed the base branch from main to version-3 March 20, 2026 09:59
@svelte-docs-bot

Copy link
Copy Markdown

Comment thread packages/kit/src/types/internal.d.ts
Comment thread packages/kit/src/exports/vite/index.js Outdated
Comment on lines +1065 to +1069
const resolved = await this.resolve(id, importer, {
custom: options.custom,
isEntry: options.isEntry,
kind: options.kind,
skipSelf: true

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AFAIK we can't spread options here because the options.ssr property errors when passed here now

teemingc added a commit that referenced this pull request Jul 9, 2026
more cleanup separated from #15574
@teemingc

teemingc commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

I tried splitting the PR into dev changes and analyse/prerender changes but it doesn't work because they share APIs such as check_feature which have been refactored to run in an environment only.

EDIT: Let me still try....

server_manifest,
tracked_features
)) {
check_feature(route.id, route_config, feature, config.adapter);

@teemingc teemingc Jul 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Had to temporarily remove this because the implementation has changed such that it only works inside the Vite environment. It's re-added in the PR stacked on top of this

@teemingc teemingc changed the title feat: fetchable dev environments feat: make dev server compatible with FetchableDevEnvironment Jul 10, 2026
@Nic-Polumeyv

Copy link
Copy Markdown
Contributor

Read through the diff since I was curious how the environment split works. The architecture makes sense to me. A handful of things I could verify at source level, roughly in order of importance. All line refs are against the current head (987889c).

packages/kit/src/exports/vite/dev/server.js

  1. The relative URL error message contains an escaped dollar sign, so the URL is never interpolated. `Cannot use relative URL (\${info}) ...` prints the literal text ${info}. Looks like a leftover from extracting the code out of a template string. Same class of artifact as the \css`` escapes in the ssr_manifest.js comment.

  2. void check_feature(...) in __SVELTEKIT_TRACK__ turns the unsupported-feature error into an unhandled rejection. On version-3 today the check throws synchronously inside the user's read() call, so the request fails with a pointed error. With this change the request succeeds and the error only surfaces as an unhandled rejection in the SSR environment. Since route config and the adapter's supports answers are all known in the main process, maybe the results could be pushed into the environment ahead of time like the other virtual modules, so the check stays synchronous. Failing that, a .catch that routes through sveltekit:ssr-load-module-error would at least keep the signal visible.

packages/kit/src/exports/vite/index.js

  1. invalidate_module(dev_context.server, '__sveltekit/server') in the plugin_server_filesystem load hook never matches anything. __sveltekit/server is a resolve.alias to runtime/server/internal.js, and alias resolution runs before plugin resolveId, so the module graph only ever contains the resolved file path. getModuleById('__sveltekit/server') returns undefined and the call is a no-op.

  2. remote_address is a single module-level variable written by a middleware and read inside handleRequest after an await. Two concurrent requests from different clients can interleave, so getClientAddress() can report the wrong client's address. Passing it per request would avoid that, for example as a header on the dispatched Request.

  3. In the same load hook the file content is read and devalue-stringified on every load of a matched asset, but as far as I can tell nothing consumes server_assets_content yet. The node entry reads via fs.readFileSync and only the size map is used. If it's groundwork for the workerd environment PR that's fine, but until then every ?url asset gets eagerly read and serialized into module source on each load, including large binaries. Shipping only sizes for now would avoid that cost.

packages/kit/src/exports/vite/dev/ssr_manifest.js

  1. The old inline_styles wrapped each ?inline CSS load in a try/catch, with a comment saying failures can happen with dynamically imported modules. The new version awaits the imports inside Promise.all without a catch, so one failing CSS import now rejects the whole render instead of skipping that stylesheet.

packages/kit/src/core/postbuild/analyse.js

  1. list_features is a plain function with no side effects, so the empty for (const _ of list_features(...)) loop still does the full chunk graph walk per route and discards the result. Until the TODO lands, the call can be deleted outright.

Two small questions. retries: 2 -> 0 for CI in adapter-cloudflare/test/utils.js, intentional or a debugging leftover? And SSRManifest.base is added to the public type and generate_manifest but I couldn't find a consumer, is that for a follow-up?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remove Emulator? Use the new Vite Runtime API to support server-side HMR correctly Adapter api for dev mode support

4 participants