diff --git a/.changeset/base-and-lazy-asset-urls.md b/.changeset/base-and-lazy-asset-urls.md
new file mode 100644
index 0000000..748585d
--- /dev/null
+++ b/.changeset/base-and-lazy-asset-urls.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+Base-path and lazy-asset URL handling fixed across the start mode surfaces (#298, #299, #300). Vite's base middleware strips the configured `base` from `req.url` before post middlewares run, but the built handler compares the request pathname against the base-prefixed server-function endpoint and hands the URL to application code — so under a non-root `base`, `vite preview` never dispatched `/base/_server` (requests fell through to page rendering) and dev page SSR saw base-stripped URLs that production would never send. The plugin's node adapters (the preview middleware, the start-mode dev SSR middleware, and the server-function dev middleware when the stripped endpoint form matched) now restore the base before constructing the web `Request`, so the handler sees production-shaped URLs on every surface. The dev asset resolver's lazy module URLs get the same treatment: they were emitted as `"/" + key`, which Vite rejects outside a non-root base and which mis-normalizes for root-external modules — they are now base-prefixed, and keys outside the Vite root (`../…`, e.g. sibling workspace packages) resolve to `/@fs/` URLs; the generated `virtual:solid-manifest` dev fallback mirrors the same logic. Finally, module query strings survive the lazy asset lookup: `resolveLazyModuleUrls` and the SSR `$$moduleUrl` injection kept only the queryless path while Rollup keys the facade chunk (and the Vite manifest entry) by the queried module id, so `lazy(() => import('./Panel.tsx?variant=a'))` missed its production manifest entry and could load different plugin output in dev — the query is now part of the asset key end to end, matching the manifest and the dev URL.
diff --git a/.changeset/boundary-guard-dep-scan.md b/.changeset/boundary-guard-dep-scan.md
new file mode 100644
index 0000000..0d4d9b9
--- /dev/null
+++ b/.changeset/boundary-guard-dep-scan.md
@@ -0,0 +1,20 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+Boundary guard no longer aborts Vite's dependency scan. The dep scanner
+(`vite:dep-scan`) crawls the raw import graph from the plugin's injected
+scan entries before any directive transform runs, so it walks straight
+through `'use server'` modules into genuinely server-only code — and the
+`server-only` marker's client-graph guard treated that as a violation,
+failing the whole scan on every cold `vite dev` start ("Failed to run
+dependency scan. Skipping dependency pre-bundling.") for any app whose
+server functions reach `server-only` code. The graph is legal once
+transforms split it, so the guard now stands down on scanner resolves
+(the `scan` flag Vite sets on plugin-container resolve options, both the
+esbuild scanner in v6/7 and the rolldown one in v8) while still claiming
+the specifier — the scanner must not chase `server-only`/`client-only` as
+missing bare dependencies, which would abort the scan all the same. Real
+dev and build module graphs resolve without the flag and stay fully
+guarded: a client-side import of a `server-only` module is still a build
+error naming the importer.
diff --git a/.changeset/bridge-failure-not-cached.md b/.changeset/bridge-failure-not-cached.md
new file mode 100644
index 0000000..9bf479f
--- /dev/null
+++ b/.changeset/bridge-failure-not-cached.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+The dev-manifest bridge resolver no longer caches failed lookups. The convergence cache introduced for the nested-lazy render-pass fix stored the `null` a bridge failure resolves to, so one transient miss (dev server briefly unreachable, non-OK response) silently stripped that module's client assets — and its hydration preload entry — for the rest of the dev session. Only successful answers are cached now; failures keep logging loudly and stay retryable, while in-flight dedupe still hands retries of the same render pass a stable promise, so convergence is unaffected.
diff --git a/.changeset/bump-solid-beta-21.md b/.changeset/bump-solid-beta-21.md
new file mode 100644
index 0000000..fc46f36
--- /dev/null
+++ b/.changeset/bump-solid-beta-21.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+update to solid 2.0.0-beta.21 and @dom-expressions/compiler 0.50.0-next.24
diff --git a/.changeset/bump-solid-beta-22.md b/.changeset/bump-solid-beta-22.md
new file mode 100644
index 0000000..955360e
--- /dev/null
+++ b/.changeset/bump-solid-beta-22.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+update to solid 2.0.0-beta.22 and @dom-expressions/compiler 0.50.0-next.25
diff --git a/.changeset/bump-solid-beta-24.md b/.changeset/bump-solid-beta-24.md
new file mode 100644
index 0000000..6b146c6
--- /dev/null
+++ b/.changeset/bump-solid-beta-24.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+update to solid 2.0.0-beta.24 and @dom-expressions/compiler 0.50.0-next.29
diff --git a/.changeset/bump-solid-beta-25.md b/.changeset/bump-solid-beta-25.md
new file mode 100644
index 0000000..94ba18b
--- /dev/null
+++ b/.changeset/bump-solid-beta-25.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+update to solid 2.0.0-beta.25
diff --git a/.changeset/bump-solid-beta-26.md b/.changeset/bump-solid-beta-26.md
new file mode 100644
index 0000000..9535296
--- /dev/null
+++ b/.changeset/bump-solid-beta-26.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+update to solid 2.0.0-beta.26 and @dom-expressions/compiler 0.50.0-next.31
diff --git a/.changeset/bump-solid-beta-28.md b/.changeset/bump-solid-beta-28.md
new file mode 100644
index 0000000..84b4228
--- /dev/null
+++ b/.changeset/bump-solid-beta-28.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+update to solid 2.0.0-beta.28 and @dom-expressions/compiler 0.50.0-next.33
diff --git a/.changeset/bump-solid-beta-29.md b/.changeset/bump-solid-beta-29.md
new file mode 100644
index 0000000..2f2325e
--- /dev/null
+++ b/.changeset/bump-solid-beta-29.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+update to solid 2.0.0-beta.29 and @dom-expressions/compiler 0.50.0-next.34 (the single-flight handler wiring imports `frameTransformFlightResult` from `@solidjs/web`, which first ships in that solid release)
diff --git a/.changeset/bump-solid-beta-30.md b/.changeset/bump-solid-beta-30.md
new file mode 100644
index 0000000..9fe28c2
--- /dev/null
+++ b/.changeset/bump-solid-beta-30.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+update to solid 2.0.0-beta.30 and @dom-expressions/compiler 0.50.0-next.35 (the compiler's module-URL pass now also annotates `clientOnly(() => import("x"))` calls — third argument, options slot padded with `void 0` — so the server half can emit early modulepreload hints for browser-only modules; `resolveLazyModuleUrls` already resolves the placeholder position-independently)
diff --git a/.changeset/bump-solid-beta-32.md b/.changeset/bump-solid-beta-32.md
new file mode 100644
index 0000000..a56d52d
--- /dev/null
+++ b/.changeset/bump-solid-beta-32.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+update to solid 2.0.0-beta.32 and @dom-expressions/compiler 0.50.0-next.40 — `commitEventResponse` now ships in `@solidjs/web`, so the SSR start-mode handler imports it by name and the namespace-import fallback for pre-.40 runtimes is deleted; the solid floor moves to beta.32 accordingly
diff --git a/.changeset/bump-solid-rc-0.md b/.changeset/bump-solid-rc-0.md
new file mode 100644
index 0000000..3657d12
--- /dev/null
+++ b/.changeset/bump-solid-rc-0.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+update to solid 2.0.0-rc.0 — the solid-js/@solidjs/web peer ranges and the babel-preset-solid dependency move from `>=2.0.0-beta.32 <2.0.0-experimental.0` to `^2.0.0-rc.0`, admitting the rc line (which the old experimental-capped upper bound excluded, since `experimental` sorts before `rc`), still flooring above the hazardous 2.0.0-experimental.* publishes, and auto-graduating to stable 2.x
diff --git a/.changeset/clean-lazy-entries.md b/.changeset/clean-lazy-entries.md
new file mode 100644
index 0000000..fe6fd02
--- /dev/null
+++ b/.changeset/clean-lazy-entries.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+Reclassify emitted lazy facade chunks as dynamic entries in the raw output bundle so downstream plugins do not mistake them for application entries.
diff --git a/.changeset/client-build-first-normal-order.md b/.changeset/client-build-first-normal-order.md
new file mode 100644
index 0000000..8c59b6e
--- /dev/null
+++ b/.changeset/client-build-first-normal-order.md
@@ -0,0 +1,32 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+Move the `solid:client-build-first` buildApp hook from `pre` to normal
+order, and make its post-order `/complete` companion defer to any other
+plugin that declares a non-pre `buildApp` hook of its own.
+
+Pre-order `buildApp` hooks are where host plugins do destructive
+preparation: nitro v3's `nitro:prepare` rm -rf's the output directory from
+a pre-order hook. Sorted at `pre`, our client build could run before that
+cleanup (hook order within `pre` follows plugin registration), so the
+client bundle and manifest it had just emitted were wiped, the subsequent
+server build baked in the manifest-less fallback, and the production build
+served 500s with no client assets — the `solid({ ssr })` + `nitro()`
+composition was broken out of the box.
+
+Normal order still satisfies the hook's original purpose (client before
+any server-first orchestrator): config-level `builder.buildApp`
+orchestrators (@cloudflare/vite-plugin builds workers before the client)
+are invoked by Vite only after all pre- and normal-order plugin hooks, and
+hook-based orchestrators (nitro's `nitro:main`, cloudflare's companion
+hook) declare post order. The `/complete` hook now also treats another
+plugin's non-pre `buildApp` hook as a claim on the app build even before
+it runs, instead of preempting a post-order orchestrator's staged build
+(nitro prerenders and copies public assets before its final server bundle,
+and knows which environments to skip). Plain `builder: {}` setups keep the
+reinstated build-everything fallback, unchanged.
+
+Covered by a new `examples/turnkey` e2e mode (builder-prepare) that builds against a
+nitro-shaped host: a pre-order output-wiping hook plus a post-order
+ssr-building orchestrator that skips already-built environments.
diff --git a/.changeset/client-manifest-outdir.md b/.changeset/client-manifest-outdir.md
new file mode 100644
index 0000000..f6d7de6
--- /dev/null
+++ b/.changeset/client-manifest-outdir.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+Resolve the client build manifest from the client environment's actual output directory in builder-mode builds instead of assuming `dist/client`. Frameworks that relocate the client outDir (e.g. SolidStart's nitro plugin building to `.solid-start/client`) previously got the dev-shaped fallback manifest in their SSR bundle, breaking production asset resolution.
diff --git a/.changeset/compiler-next-37.md b/.changeset/compiler-next-37.md
new file mode 100644
index 0000000..8c7c765
--- /dev/null
+++ b/.changeset/compiler-next-37.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/vite-plugin": patch
+---
+
+Update @dom-expressions/compiler to 0.50.0-next.37: the directive DCE now removes an import declaration whose surviving specifiers are all type-only after pruning (solid-start #2273), instead of leaving a bare server-module edge in the client bundle.
diff --git a/.changeset/compiler-package-rename.md b/.changeset/compiler-package-rename.md
new file mode 100644
index 0000000..55a1d2c
--- /dev/null
+++ b/.changeset/compiler-package-rename.md
@@ -0,0 +1,8 @@
+---
+"@solidjs/vite-plugin": patch
+---
+
+Follow the native compiler package rename: `@dom-expressions/jsx-compiler` is
+now `@dom-expressions/compiler` (the binary is growing beyond the JSX
+transform, so the name no longer singles out one pass). No option or behavior
+changes — `jsx: "native"` works exactly as before.
diff --git a/.changeset/dev-asset-resolver-convergence.md b/.changeset/dev-asset-resolver-convergence.md
new file mode 100644
index 0000000..f29de6a
--- /dev/null
+++ b/.changeset/dev-asset-resolver-convergence.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+The dev asset resolver now caches per module key and answers synchronously once a key's assets are known (in-flight walks are deduped; any watcher event drops the cache so dev CSS stays fresh). Server-side `lazy()` re-requests a module's assets on every retry of a suspended render pass — the router's nested outlets re-create the component per retry — and an always-async resolver suspends every retry on a brand-new promise, so the pass never converges: any nested route hung `vite dev`, or overflowed the render stack (one resume closure nests per cycle) and the escaped rejection killed the dev server. The build manifest never looped because it answers synchronously; dev now matches it after the first resolution. The HTTP bridge resolver for isolated SSR runners (nitro dev worker, workerd) gets the same convergence cache.
diff --git a/.changeset/dev-css-and-client-manifest.md b/.changeset/dev-css-and-client-manifest.md
new file mode 100644
index 0000000..c5e80ce
--- /dev/null
+++ b/.changeset/dev-css-and-client-manifest.md
@@ -0,0 +1,25 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+Dev SSR CSS collection and a client-side asset manifest:
+
+- In dev, `virtual:solid-manifest` now exports an asset resolver
+ `{ resolve, resolveSync }` instead of a stub object. When server-side
+ `lazy()` resolves a module, `resolve` walks Vite's SSR module graph and
+ returns its transitively imported CSS as inline-style descriptors, so dev
+ SSR streams fully styled markup (no FOUC) as ``;
+}
+
+/**
+ * Browser URL for a lazy module's dev asset key (a project-root-relative
+ * path, query included when the module identity carries one). Vite only
+ * serves module URLs under the configured `base`, so it is always applied;
+ * root-external keys (`../…`, e.g. sibling workspace packages) can't be
+ * expressed as root-relative URLs at all — they get Vite's `/@fs/` form on
+ * the resolved absolute path instead. Mirrored by the generated fallback in
+ * `devManifestCode` (src/index.ts) — keep the two in sync.
+ */
+export function devModuleUrl(root: string, base: string, key: string): string {
+ const queryIndex = key.indexOf('?');
+ const file = queryIndex === -1 ? key : key.slice(0, queryIndex);
+ const query = queryIndex === -1 ? '' : key.slice(queryIndex);
+ if (!file.startsWith('..')) return joinBase(base, '/' + key);
+ const absolute = path.resolve(root, file).split(path.sep).join('/');
+ // Vite's fs URLs collapse the leading slash: /@fs/Users/… (and keep the
+ // drive letter on Windows: /@fs/C:/…).
+ return joinBase(base, '/@fs/' + absolute.replace(/^\//, '') + query);
+}
+
+export function createDevAssetResolver(server: ViteDevServer): DevAssetResolver {
+ // Server-side lazy() re-requests a module's assets on every retry of a
+ // suspended render pass (retries re-create the component). The build
+ // manifest answers those repeats synchronously and the pass converges; an
+ // always-async resolver instead suspends every retry on a brand-new
+ // promise, so a pass whose retry path re-creates the lazy component (a
+ // nested route's outlet does) loops forever — each cycle nests one resume
+ // closure until the render stack overflows and the escaped rejection kills
+ // the dev server. So: dedupe in-flight walks per key and answer
+ // synchronously once a key's assets are known. Any watcher event drops the
+ // cache — the next request re-walks the updated module graph, keeping dev
+ // CSS fresh.
+ const resolved = new Map();
+ const pending = new Map>();
+ const { root, base } = server.config;
+ let generation = 0;
+ server.watcher.on('all', () => {
+ generation++;
+ resolved.clear();
+ pending.clear();
+ });
+
+ const resolve = function resolveDevAssets(
+ key: string,
+ ): ResolvedAssets | Promise {
+ const cached = resolved.get(key);
+ if (cached) return cached;
+ let walk = pending.get(key);
+ if (!walk) {
+ const startedAt = generation;
+ walk = (async (): Promise => {
+ // The module's dev URL doubles as its client entry: modulepreload
+ // hint and hydration module-map value.
+ const js = [devModuleUrl(root, base, key)];
+ const css = await collectDevStyles(server, [key]);
+ return { js, css };
+ })().then(
+ (assets) => {
+ if (generation === startedAt) {
+ resolved.set(key, assets);
+ pending.delete(key);
+ }
+ return assets;
+ },
+ (error) => {
+ if (generation === startedAt) pending.delete(key);
+ throw error;
+ },
+ );
+ pending.set(key, walk);
+ }
+ return walk;
+ };
+ return {
+ resolve,
+ resolveSync: (key: string) => resolved.get(key) ?? { js: [devModuleUrl(root, base, key)], css: [] },
+ };
+}
diff --git a/src/environment.ts b/src/environment.ts
new file mode 100644
index 0000000..3fe0d12
--- /dev/null
+++ b/src/environment.ts
@@ -0,0 +1,15 @@
+/**
+ * Cross-instance-safe stand-in for vite's `isRunnableDevEnvironment`.
+ *
+ * Vite's helper is an `instanceof RunnableDevEnvironment` check against the
+ * class of whichever `vite` module the CALLER imported. When this plugin is
+ * consumed through a workspace/`link:` install, its own `vite` import can
+ * resolve to a different physical copy than the one running the dev server —
+ * and then the `instanceof` is false for every environment, silently standing
+ * the SSR/dev middlewares down. The `runner` accessor is the type's defining
+ * member (`RunnableDevEnvironment` is exactly "a DevEnvironment with a
+ * runner"), so presence-check it instead of trusting class identity.
+ */
+export function isRunnableEnvironment(environment: unknown): boolean {
+ return !!environment && typeof environment === 'object' && 'runner' in environment;
+}
diff --git a/src/http.ts b/src/http.ts
new file mode 100644
index 0000000..60bd096
--- /dev/null
+++ b/src/http.ts
@@ -0,0 +1,93 @@
+// Node <-> web-standard request/response bridging shared by the plugin's dev
+// middlewares (server functions and SSR). The virtual production handlers
+// speak web Request/Response only; this is the node:http glue the dev server
+// needs to talk to them.
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import { Readable } from 'node:stream';
+
+/**
+ * `urlPath` overrides `req.url` when the middleware needs to dispatch a
+ * different URL than the one node saw — the dev middlewares use it to
+ * restore the configured Vite `base` that the dev/preview base middleware
+ * stripped, so the handler always sees production-shaped URLs.
+ */
+export function webRequestFromNode(req: IncomingMessage, urlPath?: string): Request {
+ const url = new URL(urlPath ?? req.url ?? '/', `http://${req.headers.host || 'localhost'}`);
+ const headers = new Headers();
+ for (const [key, value] of Object.entries(req.headers)) {
+ if (value === undefined) continue;
+ if (Array.isArray(value)) {
+ for (const item of value) headers.append(key, item);
+ } else {
+ headers.append(key, value);
+ }
+ }
+ const method = req.method || 'GET';
+ const body =
+ method === 'GET' || method === 'HEAD'
+ ? undefined
+ : (Readable.toWeb(req) as unknown as ReadableStream);
+ return new Request(url, {
+ method,
+ headers,
+ body,
+ // undici requires half-duplex for streamed request bodies.
+ ...(body ? { duplex: 'half' } : {}),
+ } as RequestInit);
+}
+
+export async function sendWebResponse(res: ServerResponse, response: Response): Promise {
+ res.statusCode = response.status;
+ // set-cookie is the one header that must not be comma-joined.
+ const cookies: string[] | undefined = (response.headers as any).getSetCookie?.();
+ response.headers.forEach((value, key) => {
+ if (key !== 'set-cookie') res.setHeader(key, value);
+ });
+ if (cookies && cookies.length) res.setHeader('set-cookie', cookies);
+ if (!response.body) {
+ res.end();
+ return;
+ }
+ const reader = response.body.getReader();
+ res.on('close', () => {
+ reader.cancel().catch(() => {});
+ });
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ // A response whose client already went away never emits 'drain'
+ // (writes are no-ops), so a backpressure wait must also settle on
+ // 'close'/'error' or an aborted streaming response parks this promise
+ // — and the reader and Response it holds — forever.
+ if (res.destroyed) return;
+ if (!res.write(value)) {
+ const drained = await new Promise((resolve) => {
+ const settle = (ok: boolean) => {
+ res.off('drain', onDrain);
+ res.off('close', onGone);
+ res.off('error', onGone);
+ resolve(ok);
+ };
+ const onDrain = () => settle(true);
+ const onGone = () => settle(false);
+ res.once('drain', onDrain);
+ res.once('close', onGone);
+ res.once('error', onGone);
+ });
+ // Client gone mid-stream; the 'close' handler cancels the reader.
+ if (!drained) return;
+ }
+ }
+ res.end();
+ } catch {
+ res.destroy();
+ }
+}
+
+export function joinBase(base: string, pathname: string): string {
+ // Absolute-URL or relative bases (CDN deploys, './') don't prefix
+ // same-origin server paths.
+ if (!base.startsWith('/')) return pathname;
+ return (base.endsWith('/') ? base.slice(0, -1) : base) + pathname;
+}
diff --git a/src/index.ts b/src/index.ts
index 7910275..79da6ca 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,36 +1,218 @@
import * as babel from '@babel/core';
+import type { TransformOptions as JsxCompilerOptions } from '@dom-expressions/compiler';
+import remapping from '@ampproject/remapping';
import solid from 'babel-preset-solid';
-import { readFileSync } from 'fs';
+import { existsSync, readFileSync } from 'fs';
import { mergeAndConcat } from 'merge-anything';
import { createRequire } from 'module';
-import solidRefresh from 'solid-refresh/babel';
-import type { Alias, AliasOptions, FilterPattern, Plugin } from 'vite';
+import {
+ createDevAssetResolver,
+ registerDevAssetResolver,
+ installDevManifestBridge,
+ devManifestBridgeUrl,
+ DEV_MANIFEST_REGISTRY_KEY,
+} from './dev-manifest.js';
+import { boundaryModules } from './boundary-modules.js';
+
+import { serverFunctions, type ServerFunctionsOptions } from './server-functions/index.js';
+import { SSR_HANDLER_ID, startServe, type StartOptions } from './ssr/index.js';
+import { startEnv } from './start-env.js';
+
+export { devStylePatch } from './dev-manifest.js';
+export { serverFunctions };
+export type { ServerFunctionsOptions };
+export type { ServerFunctionsFilter } from './server-functions/index.js';
+export type { StartOptions };
+import path from 'path';
+import type { FilterPattern, Plugin, ViteDevServer } from 'vite';
import { createFilter, version } from 'vite';
+import { isRunnableEnvironment } from './environment.js';
import { crawlFrameworkPkgs } from 'vitefu';
const require = createRequire(import.meta.url);
-const runtimePublicPath = '/@solid-refresh';
-const runtimeFilePath = require.resolve('solid-refresh/dist/solid-refresh.mjs');
-const runtimeCode = readFileSync(runtimeFilePath, 'utf-8');
+/**
+ * The `lazy()` module-URL placeholder contract, shared with the native
+ * compiler's `transformLazy` pass: `lazy(() => import("spec"))` calls gain a
+ * second string-literal argument of the form
+ * `"__SOLID_LAZY_MODULE__:" + spec`, which `resolveLazyModuleUrls` swaps for
+ * the project-relative resolved module path. The prefix and shape are FROZEN
+ * — the emitting side lives in @dom-expressions/compiler and must match.
+ */
+const LAZY_PLACEHOLDER_PREFIX = '__SOLID_LAZY_MODULE__:';
-const isVite6 = +version.split('.')[0] >= 6;
+/**
+ * The HMR runtime: the dev-only `solid-js/refresh` core entry. Refresh
+ * wrappers are compiled by the native `transformRefresh` pass in every mode
+ * and import the runtime through normal module resolution (the legacy
+ * solid-refresh package — whose runtime carries a known Solid 2.0 HMR bug,
+ * solid-refresh#85 — is no longer used at all).
+ */
+const REFRESH_RUNTIME_SOURCE = 'solid-js/refresh';
+
+const viteVersionMajor = +version.split('.')[0];
+const isVite8 = viteVersionMajor >= 8;
+
+const VIRTUAL_MANIFEST_ID = 'virtual:solid-manifest';
+const RESOLVED_VIRTUAL_MANIFEST_ID = '\0' + VIRTUAL_MANIFEST_ID;
+
+// In dev the virtual manifest exports a `{ resolve, resolveSync }` resolver:
+// lazy modules resolve to their dev URL plus transitively imported CSS as
+// inline-style descriptors collected from the live module graph. The resolver
+// itself lives plugin-side (it closes over the dev server) and is reached
+// through a global registry; isolated module runners that don't share
+// globals (nitro's dev worker, workerd) fall back to fetching the dev
+// server's bridge endpoint, whose URL is baked in at generation time
+// (`bridgeUrl` — null outside a live dev server, e.g. the manifest-less SSR
+// build fallback, where js-only resolution remains). Bridge failures log
+// loudly and resolve to null so the runtime's own no-assets warning stays
+// the final catch-all.
+//
+// The generated `moduleUrl` mirrors `devModuleUrl` (src/dev-manifest.ts) —
+// base-prefixed root-relative URLs, `/@fs/` for root-external keys — for the
+// degraded paths that can't reach the plugin-side resolver (no registry and
+// no bridge, or a resolveSync call before the bridge cache warms). Keep the
+// two in sync.
+const devManifestCode = (root: string, base: string, bridgeUrl: string | null) => `const registry = globalThis[Symbol.for(${JSON.stringify(
+ DEV_MANIFEST_REGISTRY_KEY,
+)})];
+const projectRoot = ${JSON.stringify(root.split(path.sep).join('/'))};
+const base = ${JSON.stringify(base.startsWith('/') ? base.replace(/\/$/, '') : '')};
+function moduleUrl(key) {
+ const queryIndex = key.indexOf("?");
+ const file = queryIndex === -1 ? key : key.slice(0, queryIndex);
+ const query = queryIndex === -1 ? "" : key.slice(queryIndex);
+ if (file.slice(0, 2) !== "..") return base + "/" + key;
+ const segments = (projectRoot + "/" + file).split("/");
+ const resolved = [];
+ for (const segment of segments) {
+ if (segment === "..") resolved.pop();
+ else if (segment && segment !== ".") resolved.push(segment);
+ }
+ return base + "/@fs/" + resolved.join("/") + query;
+}
+const jsOnly = key => ({ js: [moduleUrl(key)], css: [] });
+const bridgeUrl = ${JSON.stringify(bridgeUrl)};
+function createBridgeResolver() {
+ // Convergence cache, mirroring the in-process resolver: server-side lazy()
+ // re-requests assets on every retry of a suspended render pass, and only a
+ // synchronous answer lets the pass converge (a fresh promise per call
+ // suspends every retry anew — nested routes then loop until the render
+ // stack overflows). Cached entries can go stale after a CSS edit (no
+ // watcher reaches this side of the bridge); the HMR client replaces SSR'd
+ // dev styles on load, so staleness self-heals at hydration. Only successful
+ // answers are cached: a null (bridge failure) must stay retryable, or one
+ // transient miss would strip the module's client assets — silently — for
+ // the rest of the dev session. In-flight dedupe still gives retries of the
+ // same pass a stable promise, so convergence holds either way.
+ const cache = new Map();
+ const inFlight = new Map();
+ return {
+ resolve(key) {
+ const cached = cache.get(key);
+ if (cached) return cached;
+ let request = inFlight.get(key);
+ if (!request) {
+ request = fetchAssets(key).then(
+ (assets) => {
+ if (assets) cache.set(key, assets);
+ inFlight.delete(key);
+ return assets;
+ },
+ (error) => {
+ inFlight.delete(key);
+ throw error;
+ },
+ );
+ inFlight.set(key, request);
+ }
+ return request;
+ },
+ resolveSync: (key) => cache.get(key) || jsOnly(key),
+ };
+}
+async function fetchAssets(key) {
+ const url = new URL(bridgeUrl);
+ url.searchParams.set("key", key);
+ let response;
+ try {
+ response = await fetch(url);
+ } catch (error) {
+ console.error(
+ '[@solidjs/vite-plugin] Dev manifest bridge request failed for module key "' + key +
+ '" (' + url.href + '): ' + ((error && error.message) || error) +
+ ". SSR will render without this module's client assets, so its hydration preload entry will be missing.",
+ );
+ return null;
+ }
+ if (!response.ok) {
+ // A silent null here strips the module's client assets from the
+ // SSR'd hydration asset map and hydration fails much later with a
+ // cryptic client-side error — report the miss where it happens.
+ console.error(
+ '[@solidjs/vite-plugin] Dev manifest bridge request failed with status ' + response.status +
+ ' for module key "' + key + '" (' + url.href +
+ "). SSR will render without this module's client assets, so its hydration preload entry will be missing.",
+ );
+ return null;
+ }
+ return response.json();
+}
+export default (registry && registry[${JSON.stringify(root)}]) ||
+ (bridgeUrl ? createBridgeResolver() : { resolve: jsOnly, resolveSync: jsOnly });`;
+
+const SOLID_BUILT_INS = [
+ 'For',
+ 'Show',
+ 'Switch',
+ 'Match',
+ 'Loading',
+ 'Reveal',
+ 'Portal',
+ 'Repeat',
+ 'Dynamic',
+ 'Errored',
+];
/** Possible options for the extensions property */
export interface ExtensionOptions {
typescript?: boolean;
}
-/** Configuration options for vite-plugin-solid. */
+export type Compiler = 'babel' | 'native';
+export type SolidOptions = Omit;
+type NativeCompiler = typeof import('@dom-expressions/compiler');
+let nativeCompilerPromise: Promise | undefined;
+
+async function loadNativeCompiler() {
+ try {
+ return await (nativeCompilerPromise ??= import('@dom-expressions/compiler'));
+ } catch (error) {
+ nativeCompilerPromise = undefined;
+ const reason = error instanceof Error ? `\n\nCause: ${error.message}` : '';
+ throw new Error(
+ '@solidjs/vite-plugin: failed to load @dom-expressions/compiler, which is required ' +
+ 'in every mode (it drives the lazy, refresh, and server-function transforms; ' +
+ 'compiler: "babel" only switches the JSX transform). Your platform should get ' +
+ 'a prebuilt native binary or the @dom-expressions/compiler-wasm32-wasi fallback ' +
+ '— check that optional dependencies were installed.' +
+ reason,
+ );
+ }
+}
+
+/** Configuration options for @solidjs/vite-plugin. */
export interface Options {
/**
* A [picomatch](https://github.com/micromatch/picomatch) pattern, or array of patterns, which specifies the files
- * the plugin should operate on.
+ * the plugin should operate on. Relative patterns are resolved against the
+ * Vite root, not the invocation directory.
*/
include?: FilterPattern;
/**
* A [picomatch](https://github.com/micromatch/picomatch) pattern, or array of patterns, which specifies the files
- * to be ignored by the plugin.
+ * to be ignored by the plugin. Relative patterns are resolved against the
+ * Vite root, not the invocation directory.
*/
exclude?: FilterPattern;
/**
@@ -42,22 +224,101 @@ export interface Options {
*/
dev?: boolean;
/**
- * This will force SSR code in the produced files.
+ * Whether the app is server-rendered — one meaning everywhere.
+ *
+ * Without {@link start}: the legacy transform-only flag, unchanged.
+ * `true` enables the SSR transforms (hydratable client code, SSR server
+ * code) — you provide the entries and the server yourself.
+ *
+ * With {@link start}: selects the start mode. `true` is SSR start mode
+ * (per-request streaming render + hydration); `false`/omitted is client
+ * mode (a static document shell + client-side `render()`). Flipping a
+ * start-mode project between SPA and SSR is toggling this one boolean.
+ *
+ * The flag describes the app's initial document, not the internal
+ * pipelines — client mode still compiles the document shell through the
+ * SSR transforms to serve/prerender it.
+ *
+ * Objects are no longer accepted: start-mode options moved to {@link start}
+ * (`ssr: { ... }` from 3.0.0-next.23 and earlier becomes
+ * `start: { ... }, ssr: true`).
*
* @default false
*/
ssr?: boolean;
+ /**
+ * Start mode — Start as a mode of the plugin: it owns entries, dev
+ * serving, and the build — no index.html, no mount file, no server
+ * wiring. `start: true` is the zero-config spelling, sugar for the empty
+ * options bag `start: {}` (both mean the identical start mode with
+ * defaults; `false`/absent is off). Conventions (shared by both modes,
+ * so projects flip between them by toggling {@link ssr}): `src/App.*`
+ * (or `start.app`) is the root component; `src/Document.*` (or
+ * `start.document`) is the optional document shell; authored
+ * `src/entry-server.*` / `src/entry-client.*` (or `start.entryServer` /
+ * `start.entryClient`) replace the generated entries.
+ *
+ * With `ssr: true` — SSR start mode:
+ *
+ * - Dev: a middleware on the Vite dev server streams the rendered app for
+ * HTML-accepting GET requests — `vite` just works, no server file.
+ * - Build: a plain `vite build` produces both bundles (client to
+ * `dist/client`, server to `dist/server` via the environments/builder
+ * API). The server bundle's entry is `virtual:solid-ssr-handler`, whose
+ * `handleRequest(request)` export maps a web `Request` to a streamed
+ * `Response`; its default `{ fetch(request) }` export provides the same
+ * handler in the Fetchable shape used by deployment integrations.
+ * The normal `ssr` environment exposes it as the `index` service entry
+ * so provider Vite plugins can supply the runtime and build orchestration.
+ * - With `serverFunctions` also enabled, the prod handler serves the
+ * server-function endpoint too (in dev the server-function middleware
+ * already runs first).
+ *
+ * Without `ssr: true` — client mode:
+ *
+ * - Dev: every HTML-accepting GET streams the rendered document shell
+ * (without the app — history-fallback semantics); the generated client
+ * entry `render()`s the app into it.
+ * - Build: `vite build` emits a static `dist/client` — the shell is
+ * prerendered once through the built handler into
+ * `dist/client/index.html` with the hashed entry script and CSS links —
+ * deployable to any static host. No server bundle remains unless
+ * `serverFunctions` is enabled, in which case `dist/server` is kept and
+ * its `handleRequest` serves the endpoint (pages stay static).
+ * - Client code stays non-hydratable (`generate: 'dom'`), exactly like a
+ * plain SPA; server-only options (`entryServer`, `external`) are inert.
+ * - `vite preview` serves the static build with history fallback (and
+ * dispatches the server-function endpoint through the kept handler).
+ *
+ * @default undefined
+ */
+ start?: boolean | StartOptions;
+
+ /**
+ * JSX compiler backend to use. The default `"native"` compiles through
+ * `@dom-expressions/compiler`; `"babel"` is the escape hatch running
+ * `babel-preset-solid` instead — if native output ever differs from your
+ * expectations, set `compiler: "babel"` and file an issue (the behavioral
+ * diff between the modes is the bug report). Platforms without a prebuilt
+ * native binary (e.g. StackBlitz WebContainers) automatically use the wasm
+ * fallback; the compiler package itself is required in every mode.
+ *
+ * @default "native"
+ */
+ compiler?: Compiler;
+
/**
* This will inject HMR runtime in dev mode. Has no effect in prod. If
* set to `false`, it won't inject the runtime in dev.
*
* @default true
+ * @deprecated use `refresh` instead
*/
hot?: boolean;
/**
* This registers additional extensions that should be processed by
- * vite-plugin-solid.
+ * @solidjs/vite-plugin.
*
* @default undefined
*/
@@ -66,6 +327,11 @@ export interface Options {
* Pass any additional babel transform options. They will be merged with
* the transformations required by Solid.
*
+ * Note: with `compiler: "native"` the plugin is normally fully Babel-free
+ * (native lazy/refresh/JSX passes). Supplying custom babel options
+ * reintroduces a Babel support pass ahead of the native JSX transform to
+ * host them.
+ *
* @default {}
*/
babel?:
@@ -78,87 +344,65 @@ export interface Options {
*
* @default {}
*/
- solid?: {
- /**
- * Remove unnecessary closing tags from template strings. More info here:
- * https://github.com/solidjs/solid/blob/main/CHANGELOG.md#smaller-templates
- *
- * @default false
- */
- omitNestedClosingTags?: boolean;
-
- /**
- * Remove the last closing tag from template strings. Enabled by default even when `omitNestedClosingTags` is disabled.
- * Can be disabled for compatibility for some browser-like environments.
- *
- * @default true
- */
- omitLastClosingTag?: boolean;
-
- /**
- * Remove unnecessary quotes from template strings.
- * Can be disabled for compatibility for some browser-like environments.
- *
- * @default true
- */
- omitQuotes?: boolean;
-
- /**
- * The name of the runtime module to import the methods from.
- *
- * @default "solid-js/web"
- */
- moduleName?: string;
-
- /**
- * The output mode of the compiler.
- * Can be:
- * - "dom" is standard output
- * - "ssr" is for server side rendering of strings.
- * - "universal" is for using custom renderers from solid-js/universal
- *
- * @default "dom"
- */
- generate?: 'ssr' | 'dom' | 'universal';
-
- /**
- * Indicate whether the output should contain hydratable markers.
- *
- * @default false
- */
- hydratable?: boolean;
-
- /**
- * Boolean to indicate whether to enable automatic event delegation on camelCase.
- *
- * @default true
- */
- delegateEvents?: boolean;
-
- /**
- * Boolean indicates whether smart conditional detection should be used.
- * This optimizes simple boolean expressions and ternaries in JSX.
- *
- * @default true
- */
- wrapConditionals?: boolean;
-
- /**
- * Boolean indicates whether to set current render context on Custom Elements and slots.
- * Useful for seemless Context API with Web Components.
- *
- * @default true
- */
- contextToCustomElements?: boolean;
-
- /**
- * Array of Component exports from module, that aren't included by default with the library.
- * This plugin will automatically import them if it comes across them in the JSX.
- *
- * @default ["For","Show","Switch","Match","Suspense","SuspenseList","Portal","Index","Dynamic","ErrorBoundary"]
- */
- builtIns?: string[];
- };
+ solid?: SolidOptions;
+
+ /**
+ * Enable `"use server"` server function compilation (experimental). Pass
+ * `true` for the defaults (runtime from @solidjs/web/server-functions) or
+ * an options object to customize. The directive transform sub-plugins are
+ * emitted ahead of the JSX transform in the returned plugin array.
+ *
+ * Zero-config setup: in dev, a middleware on the Vite server handles the
+ * endpoint (default `/_server`, joined with `base`) end to end — no
+ * server-function code needed in the server entry. For production SSR
+ * builds, import `virtual:solid-server-function-handler` in the server
+ * entry and mount its `handleServerFunctionRequest(request)` export on the
+ * endpoint; it eagerly imports every module containing server functions so
+ * registrations survive tree-shaking.
+ *
+ * Hosts whose own server environment should own endpoint dispatch in dev
+ * (e.g. @cloudflare/vite-plugin, so functions run in workerd with
+ * bindings) can keep this option and set
+ * `serverFunctions: { devMiddleware: false }` — see
+ * {@link ServerFunctionsOptions.devMiddleware}. A server-only module can
+ * be pinned into the handler graph for pre-dispatch runtime registration
+ * via {@link ServerFunctionsOptions.configure}.
+ *
+ * Meta-frameworks that need to control plugin ordering themselves (e.g.
+ * relative to a file-system router) and dispatch requests through their
+ * own server should use the standalone `serverFunctions()` export instead,
+ * which never installs the dev middleware.
+ *
+ * The object form's `components` flag additionally enables server
+ * components (experimental) — `"use server"` functions returning a
+ * component, served over the same endpoint. They come essentially for
+ * free: the endpoint transform is installed automatically, and with
+ * SSR start mode (the `start` option with `ssr: true`) and generated entries
+ * the document wiring is emitted too. See
+ * {@link ServerFunctionsOptions.components}.
+ *
+ * @default undefined
+ */
+ serverFunctions?: boolean | ServerFunctionsOptions;
+
+ /** Options for the solid-refresh HMR transform (dev only). */
+ refresh?: RefreshOptions;
+}
+
+/** Options for the solid-refresh HMR transform (dev only). */
+export interface RefreshOptions {
+ /**
+ * Disable the refresh transform entirely (equivalent to the deprecated
+ * `hot: false`).
+ */
+ disabled?: boolean;
+ /**
+ * Emit per-component `signature`/`dependencies` metadata so edits only
+ * remount components whose code actually changed.
+ *
+ * @default true
+ */
+ granular?: boolean;
}
function getExtension(filename: string): string {
@@ -191,27 +435,258 @@ function getJestDomExport(setupFiles: string[]) {
);
}
-export default function solidPlugin(options: Partial = {}): Plugin {
- const filter = createFilter(options.include, options.exclude);
+function getSolidOptions(
+ options: Partial,
+ isSsr: boolean,
+ dev: boolean,
+ isTestMode = false,
+): SolidOptions {
+ let solidOptions: Pick;
+
+ if (isTestMode) {
+ // Vitest compiles with the client posture regardless of the app's `ssr`
+ // flag: component tests exercise DOM code and nothing hydrates in a
+ // test, so hydratable output would look for markers that aren't there.
+ // `generate` still follows the transform's own ssr flag, so explicit
+ // node-environment tests (renderToString) keep their server codegen.
+ solidOptions = { generate: isSsr ? 'ssr' : 'dom', hydratable: false };
+ } else if (options.start && !options.ssr) {
+ // Client start mode: client code compiles exactly like a plain SPA
+ // (dom, non-hydratable — nothing hydrates); only the document shell
+ // render goes through the SSR transforms, also non-hydratable since
+ // the shell is inert HTML the client never claims.
+ solidOptions = { generate: isSsr ? 'ssr' : 'dom', hydratable: false };
+ } else if (options.ssr) {
+ if (isSsr) {
+ solidOptions = { generate: 'ssr', hydratable: true };
+ } else {
+ solidOptions = { generate: 'dom', hydratable: true };
+ }
+ } else {
+ solidOptions = { generate: 'dom', hydratable: false };
+ }
+
+ return {
+ moduleName: '@solidjs/web',
+ builtIns: SOLID_BUILT_INS,
+ contextToCustomElements: true,
+ wrapConditionals: true,
+ ...solidOptions,
+ dev,
+ ...(options.solid || {}),
+ };
+}
+
+async function getBabelUserOptions(
+ options: Partial,
+ source: string,
+ id: string,
+ isSsr: boolean,
+) {
+ if (!options.babel) return {};
+ if (typeof options.babel !== 'function') return options.babel;
+
+ const babelOptions = options.babel(source, id, isSsr);
+ return babelOptions instanceof Promise ? await babelOptions : babelOptions;
+}
+
+function normalizeSourceMap(
+ map: string | babel.TransformOptions['inputSourceMap'] | null | undefined,
+) {
+ if (typeof map === 'string') return JSON.parse(map);
+ return map || null;
+}
+
+type ChainableMap = string | babel.TransformOptions['inputSourceMap'] | null | undefined;
+
+/**
+ * Merges the sourcemaps of sequential whole-file transforms (given in
+ * application order, earliest first) into one map tracing back to the
+ * original source.
+ */
+function combineSourcemaps(maps: ChainableMap[]) {
+ const chain = maps.filter((map): map is NonNullable => !!map);
+ if (chain.length === 0) return null;
+ if (chain.length === 1) return normalizeSourceMap(chain[0]);
+ // remapping expects most-recent-first.
+ return JSON.parse(remapping(chain.reverse() as any, () => null).toString());
+}
+
+/**
+ * Chunks emitted for lazy() targets are marked `isEntry` by Rollup even
+ * though they are semantically dynamic entries. Reclassify any entry that is
+ * dynamically imported by another chunk so the runtime's entry-asset
+ * detection (which keys off `isEntry`) can't pick a lazy facade instead of
+ * the real client entry. Works on both the Vite manifest.json shape and the
+ * raw Rollup output bundle — both key entries by name and expose
+ * `dynamicImports` / `isEntry` with the same meaning.
+ */
+function normalizeEmittedLazyEntries(manifest: Record) {
+ const dynamicKeys = new Set();
+ for (const key in manifest) {
+ const imports: string[] | undefined = manifest[key].dynamicImports;
+ if (imports) for (const dep of imports) dynamicKeys.add(dep);
+ }
+ for (const key of dynamicKeys) {
+ const entry = manifest[key];
+ if (entry && entry.isEntry) {
+ entry.isEntry = false;
+ entry.isDynamicEntry = true;
+ }
+ }
+}
+
+export default function solidPlugin(options: Partial = {}): Plugin[] {
+ if (typeof options.ssr === 'object') {
+ throw new Error(
+ '[@solidjs/vite-plugin] `ssr` now only accepts a boolean ("is the app server-rendered"); ' +
+ 'move start-mode options to `start: {}` and set `ssr: true`. Example: ' +
+ '`solid({ ssr: { document: … } })` becomes `solid({ start: { document: … }, ssr: true })`.',
+ );
+ }
+ // Recreated in configResolved: relative include/exclude patterns must
+ // resolve against the Vite root, not process.cwd() — running `vite` from
+ // outside the project would otherwise change what the filter matches.
+ let filter = createFilter(options.include, options.exclude);
+ const serverComponents =
+ typeof options.serverFunctions === 'object' && !!options.serverFunctions.components;
+ // `start: true` is sugar for the empty options bag — one start mode,
+ // two spellings — so normalize here and let everything downstream see a
+ // single shape (`false` behaves exactly like omission).
+ const turnkey: StartOptions | null =
+ options.start === true ? {} : options.start || null;
+ // `start.external` only means something when a server side exists to hand
+ // over (SSR start mode); in client mode it is a documented no-op.
+ const externalDevServer = !!options.ssr && !!turnkey?.external;
let needHmr = false;
let replaceDev = false;
+ // The live dev server, kept so the dev manifest module can bake the bridge
+ // endpoint URL in when its code is generated (see devManifestBridgeUrl).
+ let devServer: ViteDevServer | null = null;
let projectRoot = process.cwd();
let isTestMode = false;
+ let serverTestPosture = false;
+ let isBuild = false;
+ let isSsrBuild = false;
+ let base = '/';
+ let clientOutDir: string | null = null;
let solidPkgsConfig: Awaited>;
- return {
+ // The client build's manifest, read back by SSR builds. In builder-mode
+ // (single process, e.g. SolidStart's nitro plugin) the client build runs
+ // first and generateBundle records its actual outDir — authoritative, since
+ // such setups relocate it. Two-invocation builds (`vite build --outDir
+ // dist/client` then `vite build --ssr`) run in separate processes, so the
+ // SSR process falls back to the `dist/client` convention.
+ function clientManifestPath(): string | null {
+ for (const dir of [clientOutDir, 'dist/client']) {
+ if (!dir) continue;
+ const manifestPath = path.resolve(projectRoot, dir, '.vite/manifest.json');
+ if (existsSync(manifestPath)) return manifestPath;
+ }
+ return null;
+ }
+
+ // Dynamically imported project modules in the client build. Each is
+ // emitted as an explicit chunk so it always gets its own manifest entry
+ // keyed by source path — even when manualChunks or dual static/dynamic
+ // imports would otherwise fold it facade-less into a shared chunk (which
+ // would break resolveAssets lookups and hydration module preloading).
+ // Driven from moduleParsed so it covers every lazy() target, including
+ // import.meta.glob entries that never pass through the moduleUrl transform.
+ const emittedLazyChunks = new Set();
+ // Keep the emitted references because a lazy module's importer may be
+ // removed from the final bundle, leaving no dynamic-import edge to identify
+ // its facade chunk during generateBundle.
+ const emittedLazyChunkRefs: string[] = [];
+
+ // Whether the current hook invocation belongs to a client (browser) build.
+ // Builder-mode builds (e.g. SolidStart's nitro plugin) run the client and
+ // ssr environments through one Vite process with shared plugins, so the
+ // process-wide isSsrBuild flag from configResolved can't tell them apart —
+ // the per-environment consumer can. Classic two-invocation builds
+ // (`vite build` / `vite build --ssr`) fall back to the flag.
+ function isClientBuild(ctx: { environment?: { config?: { consumer?: string } } }): boolean {
+ const consumer = ctx.environment?.config?.consumer;
+ if (consumer) return consumer === 'client';
+ return !isSsrBuild;
+ }
+
+ /**
+ * Replaces lazy() moduleUrl placeholders injected by the babel plugin with
+ * project-relative module paths resolved through Vite's resolver.
+ */
+ async function resolveLazyModuleUrls(ctx: any, code: string, importer: string): Promise {
+ const placeholderRe = new RegExp('"' + LAZY_PLACEHOLDER_PREFIX + '([^"]+)"', 'g');
+ let match;
+ const resolutions: Array<{ placeholder: string; resolved: string }> = [];
+ while ((match = placeholderRe.exec(code)) !== null) {
+ const specifier = match[1];
+ const resolved = await ctx.resolve(specifier, importer);
+ if (resolved) {
+ // The query is part of the module identity: Rollup keys the facade
+ // chunk (and thus the Vite manifest entry) by the queried module id,
+ // and in dev the queried URL can serve different plugin output than
+ // the bare one — stripping it here would break both lookups.
+ const queryIndex = resolved.id.indexOf('?');
+ const file = queryIndex === -1 ? resolved.id : resolved.id.slice(0, queryIndex);
+ const query = queryIndex === -1 ? '' : resolved.id.slice(queryIndex);
+ const relativeId = path.relative(projectRoot, file).split(path.sep).join('/') + query;
+ resolutions.push({
+ placeholder: match[0],
+ resolved: '"' + relativeId + '"',
+ });
+ }
+ }
+ for (const { placeholder, resolved } of resolutions) {
+ code = code.replace(placeholder, resolved);
+ }
+ return code;
+ }
+
+ /**
+ * SSR transforms append a `$$moduleUrl` export carrying the module's
+ * client-manifest key (project-relative source path, module query
+ * included — a queried module is its own identity, with its own facade
+ * chunk and manifest entry). Server-side `lazy()` reads it off the
+ * resolved module when the callsite has no static import specifier to
+ * transform — e.g. `lazy` over an `import.meta.glob` entry — so asset
+ * resolution and hydration preloading still work. Client builds are
+ * untouched.
+ */
+ function injectSsrModuleId(code: string, id: string, isSsr: boolean): string {
+ if (!isSsr || /node_modules/.test(id) || code.includes('$$moduleUrl')) return code;
+ const queryIndex = id.indexOf('?');
+ const file = queryIndex === -1 ? id : id.slice(0, queryIndex);
+ const query = queryIndex === -1 ? '' : id.slice(queryIndex);
+ const relativeId = path.relative(projectRoot, file).split(path.sep).join('/') + query;
+ return code + `\nexport const $$moduleUrl = ${JSON.stringify(relativeId)};\n`;
+ }
+
+ const mainPlugin: Plugin = {
name: 'solid',
enforce: 'pre',
async config(userConfig, { command }) {
// We inject the dev mode only if the user explicitly wants it or if we are in dev (serve) mode
replaceDev = options.dev === true || (options.dev !== false && command === 'serve');
- projectRoot = userConfig.root;
+ projectRoot = userConfig.root || projectRoot;
isTestMode = userConfig.mode === 'test';
-
- if (!userConfig.resolve) userConfig.resolve = {};
- userConfig.resolve.alias = normalizeAliases(userConfig.resolve && userConfig.resolve.alias);
+ // Per-vitest-project posture: the client posture (browser conditions,
+ // dom codegen, jsdom default) is right for DOM component tests but
+ // wrong for server-runtime unit tests. A project that explicitly opts
+ // into a server runtime — `test: { environment: 'node' }` (or
+ // 'edge-runtime') — gets the server posture end to end: no browser
+ // condition injection, so the framework resolves its real server
+ // build (isServer true) with no inline/alias workarounds. DOM
+ // environments (the jsdom default, happy-dom, browser mode) keep the
+ // client posture. Each vitest project resolves its own config, so the
+ // hooks below see the posture of the project they serve.
+ serverTestPosture =
+ isTestMode &&
+ ((userConfig as any).test?.environment === 'node' ||
+ (userConfig as any).test?.environment === 'edge-runtime');
solidPkgsConfig = await crawlFrameworkPkgs({
viteUserConfig: userConfig,
@@ -223,9 +698,7 @@ export default function solidPlugin(options: Partial = {}): Plugin {
});
// fix for bundling dev in production
- const nestedDeps = replaceDev
- ? ['solid-js', 'solid-js/web', 'solid-js/store', 'solid-js/html', 'solid-js/h']
- : [];
+ const nestedDeps = replaceDev ? ['solid-js', '@solidjs/web'] : [];
const userTest = (userConfig as any).test ?? {};
const test = {} as any;
@@ -236,20 +709,38 @@ export default function solidPlugin(options: Partial = {}): Plugin {
? [userTest.setupFiles]
: userTest.setupFiles || [];
- if (!userTest.environment && !options.ssr) {
+ // Regardless of the app's `ssr` flag: tests run with the client
+ // posture (DOM component tests are the norm), so the default test
+ // environment is a DOM. Node-environment tests opt in explicitly.
+ // Browser-mode projects get the real browser DOM, so don't default
+ // them to jsdom — vitest probes for the environment's package at
+ // startup and fails the run if jsdom isn't installed. They fall
+ // back to vitest's own node default (no package probe).
+ if (!userTest.environment && !userTest.browser?.enabled) {
test.environment = 'jsdom';
}
- if (
+ if (serverTestPosture) {
+ // The worker pool is shared across the whole vitest workspace and
+ // imports externalized deps natively with `--conditions` derived
+ // from the ROOT config — which carries the client posture's
+ // 'browser'. Inline the framework so every resolution goes through
+ // THIS project's (server) conditions instead: one server-build
+ // instance end to end (request-event storage included).
+ if (!userTest.server?.deps?.inline) {
+ test.server = { deps: { inline: [/solid-js/, /@solidjs[+/]web/] } };
+ }
+ } else if (
!userTest.server?.deps?.external?.find((item: string | RegExp) =>
/solid-js/.test(item.toString()),
)
) {
test.server = { deps: { external: [/solid-js/] } };
}
- if (!userTest.browser?.enabled) {
- // vitest browser mode already has bundled jest-dom assertions
- // https://main.vitest.dev/guide/browser/assertion-api.html#assertion-api
+ // jest-dom's DOM matchers have no place in a server-posture project;
+ // vitest browser mode already has bundled jest-dom assertions
+ // https://main.vitest.dev/guide/browser/assertion-api.html#assertion-api
+ if (!userTest.browser?.enabled && !serverTestPosture) {
const jestDomImport = getJestDomExport(userSetupFiles);
if (jestDomImport) {
test.setupFiles = [jestDomImport];
@@ -263,23 +754,39 @@ export default function solidPlugin(options: Partial = {}): Plugin {
* .tsx & .jsx files are handled by us
*/
// esbuild: { include: /\.ts$/ },
+ // resolve.conditions is handled per-environment in configEnvironment.
resolve: {
- conditions: isVite6
- ? undefined
- : [
- 'solid',
- ...(replaceDev ? ['development'] : []),
- ...(userConfig.mode === 'test' && !options.ssr ? ['browser'] : []),
- ],
dedupe: nestedDeps,
- alias: [{ find: /^solid-refresh$/, replacement: runtimePublicPath }],
},
optimizeDeps: {
- include: [...nestedDeps, ...solidPkgsConfig.optimizeDeps.include],
+ include: [
+ ...nestedDeps,
+ // Dev refresh wrappers import the solid-js/refresh runtime in
+ // every mode; pre-bundle it up front so its discovery doesn't
+ // trigger a re-optimize + full reload on first use.
+ ...(command === 'serve' && options.hot !== false && !options.refresh?.disabled
+ ? [REFRESH_RUNTIME_SOURCE]
+ : []),
+ // The server-components client runtime is imported by the
+ // (virtual) client entry, and compiled function references
+ // import the server-function client runtime; pre-bundle both up
+ // front — in one optimizer pass — so a mid-session discovery
+ // can't trigger a re-optimize + full reload, and both entries
+ // share one instance of the transport config module (the
+ // server-components runtime installs its response policy there).
+ ...(command === 'serve' && serverComponents
+ ? ['@solidjs/web/frames', '@solidjs/web/server-functions']
+ : []),
+ ...solidPkgsConfig.optimizeDeps.include,
+ ],
exclude: solidPkgsConfig.optimizeDeps.exclude,
+ // Vite 8+ uses Rolldown for dependency scanning. Rolldown defaults to
+ // React's automatic JSX runtime for .tsx files, injecting a
+ // react/jsx-dev-runtime import. Tell it to preserve JSX as-is since
+ // this plugin handles JSX transformation via babel-preset-solid.
+ ...(isVite8 ? { rolldownOptions: { transform: { jsx: 'preserve' as const } } } : {}),
},
- ...(!isVite6 ? { ssr: solidPkgsConfig.ssr } : {}),
- ...(test.server ? { test } : {}),
+ ...(Object.keys(test).length ? { test } : {}),
};
},
@@ -299,13 +806,19 @@ export default function solidPlugin(options: Partial = {}): Plugin {
config.resolve.conditions = [
'solid',
...(replaceDev ? ['development'] : []),
- ...(isTestMode && !opts.isSsrTargetWebworker && !options.ssr ? ['browser'] : []),
+ // Tests resolve the browser builds even when the app is
+ // server-rendered — the client posture applies to the whole test
+ // pipeline, not just the codegen. Projects that explicitly opt into
+ // a server runtime (`test.environment: 'node'` / 'edge-runtime')
+ // keep the default server conditions instead, so the framework's
+ // real server build resolves (isServer true).
+ ...(isTestMode && !serverTestPosture && !opts.isSsrTargetWebworker ? ['browser'] : []),
...config.resolve.conditions,
];
- // Set resolve.noExternal and resolve.external for SSR environment (Vite 6+)
+ // Set resolve.noExternal and resolve.external for the SSR environment.
// Only set resolve.external if noExternal is not true (to avoid conflicts with plugins like Cloudflare)
- if (isVite6 && name === 'ssr' && solidPkgsConfig) {
+ if (name === 'ssr' && solidPkgsConfig) {
if (config.resolve.noExternal !== true) {
config.resolve.noExternal = [
...(Array.isArray(config.resolve.noExternal) ? config.resolve.noExternal : []),
@@ -320,15 +833,156 @@ export default function solidPlugin(options: Partial = {}): Plugin {
},
configResolved(config) {
- needHmr = config.command === 'serve' && config.mode !== 'production' && options.hot !== false;
+ isBuild = config.command === 'build';
+ isSsrBuild = !!config.build.ssr;
+ base = config.base;
+ projectRoot = config.root;
+ filter = createFilter(options.include, options.exclude, { resolve: projectRoot });
+ if (serverComponents && !(options.start && options.ssr)) {
+ config.logger.warn(
+ '[@solidjs/vite-plugin] serverFunctions.components is set without SSR start mode (the `start` ' +
+ 'option with `ssr: true`), so the plugin only installs the endpoint response transform ' +
+ '(server functions returning components stream correctly). The document wiring — render ' +
+ 'plugin, bootstrap script, and the client-side installServerComponents() call — is ' +
+ "emitted by SSR start mode's generated entries; without it, server components only mount " +
+ 'from post-boot streams and your client code must call installServerComponents() itself.',
+ );
+ }
+ needHmr =
+ config.command === 'serve' &&
+ config.mode !== 'production' &&
+ options.hot !== false &&
+ !options.refresh?.disabled;
+ },
+
+ configureServer(server) {
+ devServer = server;
+ // Dev asset resolution for SSR: the virtual manifest module (evaluated
+ // in the SSR environment) picks this resolver up through the global
+ // registry keyed by project root — or, from isolated module runners
+ // that don't share globals with this process, through the HTTP bridge
+ // endpoint the middleware serves.
+ if (options.ssr || options.start) {
+ registerDevAssetResolver(server.config.root, createDevAssetResolver(server));
+ installDevManifestBridge(server);
+ }
+ if (!needHmr) return;
+ // When a module has a syntax error, Vite sends the error overlay via
+ // WebSocket but the failed import triggers invalidation in solid-refresh.
+ // This propagates up to @refresh reload boundaries (e.g. document-level
+ // App components in SSR), causing a full-reload that overrides the overlay.
+ // We suppress update/full-reload messages that immediately follow an error.
+ const hot = server.hot ?? (server as any).ws;
+ if (!hot) return;
+ let lastErrorTime = 0;
+ const origSend = hot.send.bind(hot);
+ hot.send = function (this: any, ...args: any[]) {
+ const payload = args[0];
+ if (typeof payload === 'object' && payload) {
+ if (payload.type === 'error') {
+ lastErrorTime = Date.now();
+ } else if (
+ lastErrorTime &&
+ (payload.type === 'full-reload' || payload.type === 'update')
+ ) {
+ if (Date.now() - lastErrorTime < 200) return;
+ lastErrorTime = 0;
+ }
+ }
+ return origSend(...args);
+ } as typeof hot.send;
+ },
+
+ hotUpdate({ modules }) {
+ // solid-refresh only injects HMR boundaries into client modules, so
+ // non-client environments have no accept handlers. Without this, Vite
+ // would see no boundaries and send full-reload messages that race with
+ // client-side HMR updates. Provider-owned (non-runnable) environments
+ // fall through instead: their plugin needs the real module list to
+ // invalidate its remote runner, and its channel never reaches the
+ // browser websocket.
+ if (this.environment.name !== 'client' && isRunnableEnvironment(this.environment)) {
+ // Returning [] also suppresses the signal environment-runner based
+ // servers (e.g. nitro's dev worker) rely on to re-evaluate modules,
+ // leaving SSR stale until a manual restart. Send the reload on this
+ // environment's own channel — for runner-based environments that is
+ // the runner, for the default ssr environment a no-op, and never the
+ // browser websocket, so client HMR stays free of full-reload races.
+ if (modules.length > 0) {
+ this.environment.hot.send({ type: 'full-reload' });
+ }
+ return [];
+ }
},
resolveId(id) {
- if (id === runtimePublicPath) return id;
+ if (id === VIRTUAL_MANIFEST_ID) return RESOLVED_VIRTUAL_MANIFEST_ID;
+ },
+
+ moduleParsed(info) {
+ // SSR-mode client builds only: give every dynamically imported project
+ // module its own facade chunk (exports-only preserves `default`
+ // re-exports) so it keeps a manifest entry keyed by its source path
+ // even when chunk grouping would otherwise absorb it. Plain SPA builds
+ // have no manifest lookups to protect.
+ if (!isBuild || !options.ssr || !isClientBuild(this)) return;
+ for (const depId of info.dynamicallyImportedIds || []) {
+ const cleanId = depId.split('?')[0];
+ if (/node_modules/.test(cleanId) || cleanId.startsWith('\0')) continue;
+ if (!/\.[mc]?[tj]sx?$/i.test(cleanId)) continue;
+ if (emittedLazyChunks.has(depId)) continue;
+ emittedLazyChunks.add(depId);
+ emittedLazyChunkRefs.push(
+ this.emitFile({ type: 'chunk', id: depId, preserveSignature: 'exports-only' }),
+ );
+ }
},
load(id) {
- if (id === runtimePublicPath) return runtimeCode;
+ if (id === RESOLVED_VIRTUAL_MANIFEST_ID) {
+ if (!isBuild) {
+ return devManifestCode(
+ projectRoot,
+ base,
+ devServer ? devManifestBridgeUrl(devServer) : null,
+ );
+ }
+ const manifestPath = clientManifestPath();
+ if (manifestPath) {
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
+ normalizeEmittedLazyEntries(manifest);
+ manifest._base = base;
+ return `export default ${JSON.stringify(manifest)};`;
+ }
+ // SSR build before the client build produced a manifest: bake in the
+ // dev-shaped fallback (registry miss degrades to js-only resolution).
+ return devManifestCode(projectRoot, base, null);
+ }
+ },
+
+ generateBundle(outputOptions, bundle) {
+ if (!isBuild || !isClientBuild(this)) return;
+ clientOutDir = outputOptions.dir ?? null;
+ // Reclassify emitted lazy facade chunks in the raw bundle (not just the
+ // serialized manifest read back later) so downstream plugins inspecting
+ // the bundle don't mistake them for application entries. Must precede
+ // the client asset map build, which keys off dynamic entries.
+ if (options.ssr) {
+ for (const ref of emittedLazyChunkRefs) {
+ let fileName: string;
+ try {
+ fileName = this.getFileName(ref);
+ } catch {
+ // Ignore references retained from a previous watch build.
+ continue;
+ }
+ const chunk = bundle[fileName];
+ if (!chunk || chunk.type !== 'chunk') continue;
+ chunk.isEntry = false;
+ chunk.isDynamicEntry = true;
+ }
+ normalizeEmittedLazyEntries(bundle);
+ }
},
async transform(source, id, transformOptions) {
@@ -345,6 +999,10 @@ export default function solidPlugin(options: Partial = {}): Plugin {
return null;
}
+ // The queried id is the module's real identity (facade chunk /
+ // manifest key / dev URL); keep it for the `$$moduleUrl` injection
+ // while the transform pipeline below works on the clean file path.
+ const moduleId = id;
id = id.replace(/\?.*$/, '');
if (!(/\.[mc]?[tj]sx$/i.test(id) || allExtensions.includes(currentFileExtension))) {
@@ -352,18 +1010,7 @@ export default function solidPlugin(options: Partial = {}): Plugin {
}
const inNodeModules = /node_modules/.test(id);
-
- let solidOptions: { generate: 'ssr' | 'dom'; hydratable: boolean };
-
- if (options.ssr) {
- if (isSsr) {
- solidOptions = { generate: 'ssr', hydratable: true };
- } else {
- solidOptions = { generate: 'dom', hydratable: true };
- }
- } else {
- solidOptions = { generate: 'dom', hydratable: false };
- }
+ const solidOptions = getSolidOptions(options, !!isSsr, replaceDev, isTestMode);
// We need to know if the current file extension has a typescript options tied to it
const shouldBeProcessedWithTypescript =
@@ -380,18 +1027,63 @@ export default function solidPlugin(options: Partial = {}): Plugin {
});
const plugins: NonNullable['plugins']> = [
'jsx',
+ 'decorators',
];
if (shouldBeProcessedWithTypescript) {
plugins.push('typescript');
}
- const opts: babel.TransformOptions = {
+ const needRefresh = needHmr && !isSsr && !inNodeModules;
+
+ const babelUserOptions = await getBabelUserOptions(options, source, id, !!isSsr);
+
+ // The native compiler picks its parser dialect from the file
+ // extension; custom extensions registered through `options.extensions`
+ // are unknown to it, so borrow a standard one matching the configured
+ // TypeScript-ness.
+ const nativeFilename = /\.(?:[mc]?[jt]s|[jt]sx)$/i.test(id)
+ ? id
+ : id + (shouldBeProcessedWithTypescript ? '.tsx' : '.jsx');
+
+ // Shared native prelude for every mode: the lazy() module-URL pass,
+ // then (dev/client/non-node_modules) the solid-refresh HMR pass, both
+ // operating on pre-JSX source. Only the JSX transform itself differs
+ // between compiler backends. Sourcemaps are collected in application
+ // order and merged at the end.
+ const compiler = await loadNativeCompiler();
+ let code = source;
+ const maps: ChainableMap[] = [];
+
+ const lazyResult = await compiler.transformLazyAsync(code, {
+ filename: nativeFilename,
+ sourceMap: true,
+ });
+ code = lazyResult.code;
+ maps.push(lazyResult.map);
+
+ if (needRefresh) {
+ const refreshResult = await compiler.transformRefreshAsync(code, {
+ filename: nativeFilename,
+ bundler: 'vite',
+ fixRender: true,
+ // The napi validator rejects explicit undefined; omit to get the
+ // pass's default (true).
+ ...(typeof options.refresh?.granular === 'boolean'
+ ? { granular: options.refresh.granular }
+ : {}),
+ jsx: false,
+ importSource: REFRESH_RUNTIME_SOURCE,
+ sourceMap: true,
+ });
+ code = refreshResult.code;
+ maps.push(refreshResult.map);
+ }
+
+ const babelBaseOptions: babel.TransformOptions = {
root: projectRoot,
filename: id,
sourceFileName: id,
- presets: [[solid, { ...solidOptions, ...(options.solid || {}) }]],
- plugins: needHmr && !isSsr && !inNodeModules ? [[solidRefresh, { bundler: 'vite' }]] : [],
ast: false,
sourceMaps: true,
configFile: false,
@@ -401,35 +1093,201 @@ export default function solidPlugin(options: Partial = {}): Plugin {
},
};
- // Default value for babel user options
- let babelUserOptions: babel.TransformOptions = {};
-
- if (options.babel) {
- if (typeof options.babel === 'function') {
- const babelOptions = options.babel(source, id, isSsr);
- babelUserOptions = babelOptions instanceof Promise ? await babelOptions : babelOptions;
- } else {
- babelUserOptions = options.babel;
+ if (options.compiler !== 'babel') {
+ if (options.babel) {
+ // Custom babel options reintroduce a Babel support pass hosting
+ // only the user's plugins, ahead of the native JSX transform.
+ const supportOptions = mergeAndConcat(
+ babelUserOptions,
+ babelBaseOptions,
+ ) as babel.TransformOptions;
+ const supportResult = await babel.transformAsync(code, supportOptions);
+ if (!supportResult) {
+ return undefined;
+ }
+ code = supportResult.code || '';
+ maps.push(supportResult.map);
}
+
+ const result = await compiler.transformAsync(code, {
+ ...solidOptions,
+ filename: nativeFilename,
+ sourceMap: true,
+ });
+ maps.push(result.map);
+
+ const finalCode = injectSsrModuleId(
+ await resolveLazyModuleUrls(this, result.code || '', id),
+ moduleId,
+ !!isSsr,
+ );
+
+ return { code: finalCode, map: combineSourcemaps(maps) };
}
- const babelOptions = mergeAndConcat(babelUserOptions, opts) as babel.TransformOptions;
+ // Babel JSX backend: one babel.transformAsync hosting the user's
+ // options plus babel-preset-solid.
+ const babelOptions = mergeAndConcat(babelUserOptions, {
+ ...babelBaseOptions,
+ presets: [[solid, solidOptions]],
+ }) as babel.TransformOptions;
- const { code, map } = await babel.transformAsync(source, babelOptions);
+ const result = await babel.transformAsync(code, babelOptions);
+ if (!result) {
+ return undefined;
+ }
+ maps.push(result.map);
+
+ const finalCode = injectSsrModuleId(
+ await resolveLazyModuleUrls(this, result.code || '', id),
+ moduleId,
+ !!isSsr,
+ );
- return { code, map };
+ return { code: finalCode, map: combineSourcemaps(maps) };
},
};
-}
-/**
- * This basically normalize all aliases of the config into
- * the array format of the alias.
- *
- * eg: alias: { '@': 'src/' } => [{ find: '@', replacement: 'src/' }]
- */
-function normalizeAliases(alias: AliasOptions = []): Alias[] {
- return Array.isArray(alias)
- ? alias
- : Object.entries(alias).map(([find, replacement]) => ({ find, replacement }));
+ // The directive transform must run before the JSX transform (it operates
+ // on raw directives, and client-mode module-level extraction must happen
+ // before templates are generated), so its sub-plugins go first. The
+ // boundary markers (`server-only` / `client-only`) are always on.
+ const plugins: Plugin[] = options.serverFunctions
+ ? [
+ boundaryModules(),
+ ...serverFunctions(options.serverFunctions === true ? {} : options.serverFunctions, {
+ devMiddleware: true,
+ externalDevServer,
+ // With start mode on (either variant), the dev middleware dispatches
+ // the endpoint through the SSR handler so user middleware and the
+ // stub-backed request event front it exactly like page SSR.
+ ...(turnkey ? { ssrHandler: SSR_HANDLER_ID } : {}),
+ }),
+ mainPlugin,
+ ]
+ : [boundaryModules(), mainPlugin];
+
+ // The `start` option opts into start-mode serving on top of the transforms;
+ // the `ssr` boolean picks the mode (a bare `ssr: true` keeps the
+ // historical transform-only behavior).
+ if (turnkey) {
+ plugins.push(
+ // Typed env (`start.env`) rides both start modes: config-time
+ // validation, the virtual:env/{server,client} modules, generated
+ // types, and the client-bundle leak scan.
+ ...startEnv(turnkey.env),
+ ...startServe(turnkey, {
+ serverFunctions: !!options.serverFunctions,
+ serverComponents,
+ ssr: !!options.ssr,
+ }),
+ );
+ }
+
+ // Builder-mode (environments API) client-before-server build ordering.
+ // Server builds read the client manifest — `virtual:solid-manifest` bakes
+ // dist/client/.vite/manifest.json in, and the persisted server-function
+ // manifest merges the client build's discoveries — so the client
+ // environment must build first. Start mode's own orchestration already
+ // orders it that way (environment definition order), but a composed setup
+ // whose orchestrator builds server environments first (e.g.
+ // @cloudflare/vite-plugin's buildApp, which builds workers before client)
+ // would bake a manifest-less fallback into the server bundle. Every user
+ // of such a setup had to hand-write this ordering plugin; absorb it.
+ //
+ // Semantics (Vite 7.1+; Vite 6 has no plugin `buildApp` hook and ignores
+ // these, keeping its build-everything default):
+ // - The first hook builds the client environment first, but only where
+ // the ordering matters: a client build that emits a manifest and
+ // actually has an input. It runs at *normal* order, deliberately not
+ // `pre`: pre-order buildApp hooks are where hosts do destructive
+ // preparation — nitro v3's `nitro:prepare` rm -rf's the whole output
+ // directory from a pre-order hook, so a pre-order client build sorted
+ // before it built into a directory that was then wiped (client assets
+ // and manifest gone, the manifest-less fallback baked into the server
+ // bundle, prod 500s). Normal order still runs before every known
+ // server-first orchestrator: a config-level `builder.buildApp`
+ // (@cloudflare/vite-plugin's workers-before-client orchestrator) is
+ // invoked by Vite only after all pre- and normal-order plugin hooks
+ // (just before the first post-order hook), and hook-based orchestrators
+ // (nitro's `nitro:main`, cloudflare's own companion hook) declare
+ // post order. Orchestrators running after skip the client via `isBuilt`
+ // (or at worst rebuild it, which is wasteful but correct — the manifest
+ // exists either way when the server environments build).
+ // - Building anything from a hook suppresses Vite's own
+ // build-all-environments fallback (it only runs when *no* environment
+ // is built), so a setup with no real orchestrator — e.g. start mode's
+ // plain `builder: {}` — would end up with only the client built. The
+ // post-order hook reinstates exactly that fallback: when nothing but
+ // our own client build has happened and no other plugin stakes a claim
+ // on the app build, build the remaining environments in definition
+ // order, precisely what Vite would have done. Another plugin declaring
+ // a non-pre `buildApp` hook counts as such a claim even when it hasn't
+ // built anything yet (its post-order hook may sort after ours):
+ // building on its behalf would break staged orchestration (nitro
+ // prerenders and copies public assets before its final server bundle)
+ // and can error outright on environments the orchestrator knows to
+ // skip (e.g. ones with no rollup input). Pre-order hooks don't count —
+ // by convention they prepare (clean output dirs) rather than build.
+ if (options.ssr) {
+ let clientBuiltFirst = false;
+ plugins.push(
+ {
+ name: 'solid:client-build-first',
+ apply: 'build',
+ async buildApp(builder) {
+ const client = builder.environments.client;
+ if (!client || client.isBuilt) return;
+ const clientBuild = client.config.build;
+ const hasInput =
+ !!clientBuild.rollupOptions?.input ||
+ existsSync(path.resolve(builder.config.root, 'index.html'));
+ if (!clientBuild.manifest || !hasInput) return;
+ await builder.build(client);
+ clientBuiltFirst = true;
+ },
+ },
+ {
+ name: 'solid:client-build-first/complete',
+ apply: 'build',
+ buildApp: {
+ order: 'post',
+ async handler(builder) {
+ if (!clientBuiltFirst) return;
+ // Another plugin declares its own (non-pre) buildApp hook — the
+ // app build is spoken for, even if that hook sorts after this
+ // one and hasn't run yet.
+ const otherOrchestrator = builder.config.plugins.some((p) => {
+ if (!p.buildApp || p.name.startsWith('solid:client-build-first')) return false;
+ return typeof p.buildApp !== 'object' || p.buildApp.order !== 'pre';
+ });
+ if (otherOrchestrator) return;
+ const environments = Object.values(builder.environments);
+ // A config-level orchestrator built something of its own — the
+ // app build is spoken for, don't build environments it may have
+ // skipped intentionally.
+ if (environments.some((env) => env.isBuilt && env.name !== 'client')) return;
+ for (const environment of environments) {
+ if (!environment.isBuilt) await builder.build(environment);
+ }
+ },
+ },
+ },
+ );
+ }
+
+ return plugins;
}
+
+export type ViteManifest = Record<
+ string,
+ {
+ file: string;
+ css?: string[];
+ isEntry?: boolean;
+ isDynamicEntry?: boolean;
+ imports?: string[];
+ }
+> & {
+ _base?: string;
+};
diff --git a/src/server-functions/compile.ts b/src/server-functions/compile.ts
new file mode 100644
index 0000000..18b52f5
--- /dev/null
+++ b/src/server-functions/compile.ts
@@ -0,0 +1,93 @@
+// The `"use server"` directive compiler. This wraps the native
+// `transformDirectives` pass from @dom-expressions/compiler (Rust/Oxc); the
+// original Babel implementation (hoisted from solid-start) lived in this
+// directory through vite-plugin-solid@c052963e and remains the frozen
+// reference for the native pass's fixture suite in dom-expressions.
+
+export interface NamedImportDefinition {
+ kind: 'named';
+ name: string;
+ source: string;
+}
+
+export interface DefaultImportDefinition {
+ kind: 'default';
+ source: string;
+}
+
+export type ImportDefinition = DefaultImportDefinition | NamedImportDefinition;
+
+export interface CompileOptions {
+ mode: 'server' | 'client';
+ env: 'production' | 'development';
+ /** The directive text (default "use server" upstream). */
+ directive: string;
+ /** Project root; function IDs hash the root-relative path. */
+ root: string;
+ definitions: {
+ register: ImportDefinition;
+ create: ImportDefinition;
+ };
+}
+
+export interface CompileResult {
+ valid: boolean;
+ code: string;
+ map: string | null;
+ functions: import('@dom-expressions/compiler').ServerFunctionMeta[];
+}
+
+type NativeCompiler = typeof import('@dom-expressions/compiler');
+let compilerPromise: Promise | undefined;
+
+// Loaded lazily so importing the plugin never pays for the native binding —
+// only setups that enable server functions load it (mirrors the JSX
+// compiler's opt-in loader in index.ts).
+async function loadCompiler(): Promise {
+ try {
+ return await (compilerPromise ??= import('@dom-expressions/compiler'));
+ } catch (error) {
+ compilerPromise = undefined;
+ const reason = error instanceof Error ? `\n\nCause: ${error.message}` : '';
+ throw new Error(
+ '@solidjs/vite-plugin: failed to load @dom-expressions/compiler (the "use server" ' +
+ 'transform). Your platform should get a prebuilt native binary or the ' +
+ '@dom-expressions/compiler-wasm32-wasi fallback — check that optional ' +
+ 'dependencies were installed.' +
+ reason,
+ );
+ }
+}
+
+/**
+ * Runs the directive transform over one module. Function IDs are
+ * `hash(relative path)-`, so the client and server builds of the
+ * same checkout agree on every ID (the wire contract) without baking
+ * machine-specific absolute paths into the output. A `valid: false` result
+ * means the module contained no matching directive and must be left
+ * untransformed. Invalid closure captures (a server function referencing a
+ * non-top-level binding) throw with the variable name and location.
+ */
+export async function compile(
+ id: string,
+ code: string,
+ options: CompileOptions,
+): Promise {
+ const { transformDirectives } = await loadCompiler();
+ const result = transformDirectives(code, {
+ filename: id,
+ root: options.root,
+ mode: options.mode,
+ env: options.env,
+ directive: options.directive,
+ sourceMap: true,
+ register: options.definitions.register,
+ create: options.definitions.create,
+ });
+ return {
+ valid: result.valid,
+ code: result.code,
+ map: result.map ?? null,
+ functions: result.functions,
+ };
+}
diff --git a/src/server-functions/index.ts b/src/server-functions/index.ts
new file mode 100644
index 0000000..808d634
--- /dev/null
+++ b/src/server-functions/index.ts
@@ -0,0 +1,683 @@
+// Hoisted from solid-start (packages/start/src/directives/index.ts).
+//
+// Standalone `"use server"` support for Vite. The compiler half of server
+// functions lives here; the runtime half (registration on the server, a
+// transport on the client) is @solidjs/web/server-functions by default —
+// the compiled output imports `registerServerReference` /
+// `createServerReference` from that specifier and the package's export
+// conditions resolve the right half per environment. Any runtime satisfying
+// that contract can be swapped in through `options.runtime` (SolidStart's,
+// or your own).
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
+import path from 'path';
+import {
+ createFilter,
+ type EnvironmentModuleGraph,
+ type FilterPattern,
+ type Plugin,
+ type ViteDevServer,
+} from 'vite';
+import { isRunnableEnvironment } from '../environment.js';
+import { joinBase, sendWebResponse, webRequestFromNode } from '../http.js';
+import { compile, type CompileOptions } from './compile.js';
+import xxHash32 from './xxhash32.js';
+
+/**
+ * Picomatch patterns selecting the modules the directive compiler runs on.
+ * Relative patterns (the defaults included) are resolved against the Vite
+ * root — not the invocation directory — so running `vite` from outside the
+ * project keeps compiling the same files. Absolute patterns are used as-is.
+ *
+ * @default include "src/**\/*.{jsx,tsx,ts,js,mjs,cjs}", exclude "node_modules/**\/*.{jsx,tsx,ts,js,mjs,cjs}"
+ */
+export interface ServerFunctionsFilter {
+ include?: FilterPattern;
+ exclude?: FilterPattern;
+}
+
+export interface ServerFunctionsOptions {
+ /**
+ * Module specifiers the compiled output imports the runtime from.
+ * Each must export `registerServerReference(id, fn)` (server) and
+ * `createServerReference(...)` (both sides).
+ *
+ * @default "@solidjs/web/server-functions" for both (the package's export
+ * conditions resolve the client or server half per environment)
+ */
+ runtime?: {
+ server: string;
+ client: string;
+ };
+ /**
+ * Virtual module id that imports every module containing server functions.
+ * Import it for side effects in your server entry so all registrations
+ * exist before requests are handled.
+ *
+ * @default "virtual:solid-server-function-manifest"
+ */
+ manifest?: string;
+ filter?: ServerFunctionsFilter;
+ /**
+ * @default "use server"
+ */
+ directive?: string;
+ /**
+ * Path the server-function transport posts to. Joined with Vite `base`.
+ * Threaded to the built-in dev middleware, the
+ * `virtual:solid-server-function-handler` module, and — whenever the
+ * resolved path differs from the runtime default (`/_server`) — runtime
+ * `configureServerFunctions{Client,Server}` calls appended to compiled
+ * modules (so custom runtimes used with a custom endpoint must export
+ * those).
+ *
+ * @default "/_server"
+ */
+ endpoint?: string;
+ /**
+ * Whether the built-in dev middleware owns the server-function endpoint on
+ * the Vite dev server. Only meaningful through the main plugin's
+ * `serverFunctions` option (the standalone `serverFunctions()` export
+ * never installs the middleware).
+ *
+ * Set `false` when another plugin's server environment should own
+ * dispatch in dev — e.g. @cloudflare/vite-plugin, whose workerd
+ * environment carries the bindings (`env`/`ctx`) your server functions
+ * need: the middleware executes functions in Vite's node-side SSR
+ * environment, so with it installed those requests never reach the
+ * worker. With the middleware off, everything else keeps working —
+ * compilation, the manifest and handler virtual modules — and endpoint
+ * requests fall through to whatever the host serves; the host loads
+ * `virtual:solid-server-function-handler` itself and dispatches through
+ * its `handleServerFunctionRequest` export, exactly like production.
+ * Functions referenced only by client code register on demand through
+ * the middleware in dev, so a host owning dispatch should side-effect
+ * import the manifest module in its server entry to cover them.
+ *
+ * When a provider owns the dev server's `ssr` environment (it isn't
+ * runnable), the middleware already stands down automatically — no need
+ * to set this. See `start.external` for the whole-server switch.
+ *
+ * @default true (stands down automatically when the `ssr` dev environment isn't runnable)
+ */
+ devMiddleware?: boolean;
+ /**
+ * Path to a server-only module (resolved relative to the Vite root, like
+ * `start.document`) that the generated
+ * `virtual:solid-server-function-handler` module side-effect imports
+ * before configuring the runtime. A guaranteed pre-dispatch home for
+ * server-side registration — typically `configureServerFunctionsServer`
+ * calls whose config the app graph can't reliably install first, e.g. a
+ * router's single-flight collector:
+ *
+ * ```ts
+ * // src/server-config.ts
+ * import { configureServerFunctionsServer } from '@solidjs/web/server-functions/server';
+ * configureServerFunctionsServer({ collectFlightData: createFlightDataCollector(router) });
+ * ```
+ *
+ * Because the module lives in the handler graph, it is evaluated before
+ * any dispatch on every surface — the dev middleware and the production
+ * handler alike — and is immune to the dev-restart race where
+ * registration living in the app graph only loads with the first page
+ * render (the handler graph loads before the first mutation). Config
+ * calls merge per key, so it composes with the plugin's own
+ * `configureServerFunctionsServer` call in the same module.
+ *
+ * @default undefined
+ */
+ configure?: string;
+ /**
+ * Enable server components (experimental): `"use server"` functions that
+ * return a component. Responses for them are served over the
+ * server-function endpoint as streamed HTML that the client runtime
+ * applies in place of the boundary (instead of decoding it as data).
+ *
+ * The plugin's dispatch surfaces — the built-in dev middleware and the
+ * `virtual:solid-server-function-handler` module — install the response
+ * transform on the server runtime automatically, so this needs no
+ * per-request wiring or server code.
+ *
+ * Document SSR of server components (rendered inline at t=0 and adopted
+ * at boot with zero endpoint requests) needs three more pieces: the
+ * render must run with the server-component render plugin, the document
+ * must carry the bootstrap script, and the client must call
+ * `installServerComponents()` before hydrating. With SSR start mode (the
+ * main plugin's `start` option with `ssr: true`) and generated entries
+ * the plugin emits all three. With authored entries those pieces live in
+ * your entry files — import them from `@solidjs/web/frames` (see the
+ * README).
+ *
+ * All of this is pure codegen: when the option is off, no reference to
+ * the server-component runtime is emitted anywhere.
+ *
+ * @default false
+ */
+ components?: boolean;
+}
+
+const DEFAULT_INCLUDE = 'src/**/*.{jsx,tsx,ts,js,mjs,cjs}';
+const DEFAULT_EXCLUDE = 'node_modules/**/*.{jsx,tsx,ts,js,mjs,cjs}';
+const DEFAULT_MANIFEST = 'virtual:solid-server-function-manifest';
+const DEFAULT_DIRECTIVE = 'use server';
+const DEFAULT_RUNTIME = '@solidjs/web/server-functions';
+// Must match the runtime's built-in default — when the resolved endpoint
+// equals it, no configure calls need to be emitted at all.
+const DEFAULT_ENDPOINT = '/_server';
+const STORAGE_SOURCE = '@solidjs/web/storage';
+// Server-only handler: importing it wires the endpoint in one line
+// (registrations via the manifest, request-event scoping, endpoint config).
+const HANDLER_ID = 'virtual:solid-server-function-handler';
+
+// Server functions referenced only from client-side code (e.g. event
+// handlers, which the SSR JSX compile drops) never get imported — or even
+// transformed — by the server build, so their registrations would be missing
+// at runtime. That's why the client transform records modules into the
+// *server* manifest set. The dev server and Vite's builder mode share one
+// process where that just works; the classic two-invocation build
+// (`vite build` then `vite build --ssr`) does not, so the client build
+// persists its findings for the SSR build to merge (mirroring the plugin's
+// dist/client/.vite/manifest.json convention).
+const PERSISTED_MANIFEST_PATH = '.vite/solid-server-functions.json';
+
+function readPersistedManifest(root: string): Set {
+ const file = path.resolve(root, 'dist/client', PERSISTED_MANIFEST_PATH);
+ if (!existsSync(file)) return new Set();
+ try {
+ const entries: string[] = JSON.parse(readFileSync(file, 'utf-8'));
+ return new Set(
+ entries.map((entry) => path.resolve(root, entry)).filter((entry) => existsSync(entry)),
+ );
+ } catch {
+ return new Set();
+ }
+}
+
+function writePersistedManifest(root: string, outDir: string, entries: Set): void {
+ const file = path.resolve(root, outDir, PERSISTED_MANIFEST_PATH);
+ mkdirSync(path.dirname(file), { recursive: true });
+ const relative = [...entries].map((entry) =>
+ path.relative(root, entry).split(path.sep).join('/'),
+ );
+ writeFileSync(file, JSON.stringify(relative, null, 2));
+}
+
+type Manifest = Record>;
+
+function createManifest(): Manifest {
+ return {
+ server: new Set(),
+ client: new Set(),
+ };
+}
+
+interface DeferredPromise {
+ reference: Promise;
+ resolve: (value: T) => void;
+ reject: (value: any) => void;
+}
+
+function createDeferredPromise(): DeferredPromise {
+ let resolve: DeferredPromise['resolve'];
+ let reject: DeferredPromise['reject'];
+
+ return {
+ reference: new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ }),
+ resolve(value) {
+ resolve(value);
+ },
+ reject(value) {
+ reject(value);
+ },
+ };
+}
+
+// The manifest can only be emitted once every module has been transformed
+// (each transform may register new entries), but Vite gives no such signal —
+// so the manifest load resolves a debounced snapshot that transforms keep
+// pushing back while they are still landing.
+class Debouncer {
+ promise: DeferredPromise;
+
+ private timeout: ReturnType | undefined;
+
+ constructor(private source: () => T) {
+ this.promise = createDeferredPromise();
+ this.defer();
+ }
+
+ defer(): void {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ this.timeout = undefined;
+ }
+ this.timeout = setTimeout(() => {
+ this.promise.resolve(this.source());
+ }, 1000);
+ }
+}
+
+function mergeManifestRecord(
+ source: Set,
+ target: Set,
+): { invalidPreload: boolean; invalidated: string[] } {
+ const current = source.size;
+ for (const entry of target) {
+ source.add(entry);
+ }
+ return {
+ invalidPreload: current !== source.size,
+ invalidated: [...source],
+ };
+}
+
+function invalidateModule(moduleGraph: EnvironmentModuleGraph, path: string) {
+ const target = moduleGraph.getModuleById(path);
+ if (target) {
+ moduleGraph.invalidateModule(target);
+ }
+}
+
+function invalidateModules(
+ server: ViteDevServer | undefined,
+ result: ReturnType,
+ manifest: string,
+): void {
+ // `environments` requires Vite 6+; older versions just miss the eager
+ // manifest invalidation (the debounced reload still converges).
+ if (server?.environments && result.invalidPreload) {
+ invalidateModule(server.environments.client.moduleGraph, manifest);
+ invalidateModule(server.environments.ssr.moduleGraph, manifest);
+ }
+}
+
+/**
+ * The second parameter is internal wiring for the main plugin's
+ * `serverFunctions` option: the built-in dev middleware is only installed
+ * through that path, so meta-frameworks composing this factory directly
+ * (and dispatching to `handleServerFunctionRequest` themselves) never race
+ * it for the endpoint. On the main plugin's path the public
+ * `options.devMiddleware` (default true) can opt back out of it.
+ */
+export function serverFunctions(
+ options: ServerFunctionsOptions = {},
+ internal: { devMiddleware?: boolean; externalDevServer?: boolean; ssrHandler?: string } = {},
+): Plugin[] {
+ const filterInclude = options.filter?.include || DEFAULT_INCLUDE;
+ const filterExclude = options.filter?.exclude || DEFAULT_EXCLUDE;
+ // Recreated in configResolved: relative patterns (the defaults included)
+ // must resolve against the Vite root, not process.cwd() — running `vite`
+ // from outside the project would otherwise silently skip every module.
+ let filter = createFilter(filterInclude, filterExclude);
+ const manifestId = options.manifest || DEFAULT_MANIFEST;
+ const directive = options.directive || DEFAULT_DIRECTIVE;
+ const runtime = options.runtime || { server: DEFAULT_RUNTIME, client: DEFAULT_RUNTIME };
+ const endpointOption = options.endpoint || DEFAULT_ENDPOINT;
+ const endpoint = endpointOption.startsWith('/') ? endpointOption : '/' + endpointOption;
+ const components = !!options.components;
+ // The middleware only exists on the main plugin's path to begin with (see the
+ // `internal` parameter doc); the public option opts out of it there.
+ const installDevMiddleware = !!internal.devMiddleware && options.devMiddleware !== false;
+
+ let env: CompileOptions['env'];
+ let root = process.cwd();
+ let base = '/';
+ let isBuild = false;
+ let isSsrBuild = false;
+ let outDir = 'dist';
+ // Endpoint with Vite `base` applied; final after configResolved, which
+ // runs before every transform/load/middleware that reads it.
+ let resolvedEndpoint = endpoint;
+ // Absolute path of the user's `configure` module; resolved (and existence-
+ // checked) in configResolved, before any handler load can read it.
+ let configureModulePath: string | null = null;
+
+ const manifest = createManifest();
+
+ const preload: Record | undefined> = {
+ server: undefined,
+ client: undefined,
+ };
+ let currentServer: ViteDevServer | undefined;
+
+ const clientOptions: Pick = {
+ directive,
+ definitions: {
+ register: {
+ kind: 'named',
+ name: 'registerServerReference',
+ source: runtime.client,
+ },
+ create: {
+ kind: 'named',
+ name: 'createServerReference',
+ source: runtime.client,
+ },
+ },
+ };
+ const serverOptions: Pick = {
+ directive,
+ definitions: {
+ register: {
+ kind: 'named',
+ name: 'registerServerReference',
+ source: runtime.server,
+ },
+ create: {
+ kind: 'named',
+ name: 'createServerReference',
+ source: runtime.server,
+ },
+ },
+ };
+
+ // A non-default endpoint (custom option, or Vite `base` prefixing the
+ // default) must reach the runtime on both sides — the client transport
+ // reads it for every fetch, the server for rendered reference `.url`s.
+ // References are only reachable through compiled modules, so appending the
+ // configure call to each guarantees it runs before any reference is used.
+ // The default endpoint appends nothing, keeping compiled output byte-
+ // identical for setups that wire the runtime themselves.
+ function endpointConfigureSnippet(mode: CompileOptions['mode']): string {
+ if (resolvedEndpoint === DEFAULT_ENDPOINT) return '';
+ const name =
+ mode === 'server' ? 'configureServerFunctionsServer' : 'configureServerFunctionsClient';
+ const source = mode === 'server' ? runtime.server : runtime.client;
+ return (
+ `\nimport { ${name} as $$configureServerFunctions } from ${JSON.stringify(source)};` +
+ `\n$$configureServerFunctions({ endpoint: ${JSON.stringify(resolvedEndpoint)} });\n`
+ );
+ }
+
+ // Dev omits the manifest import: the middleware loads the referenced
+ // module on demand instead (importing the debounced manifest would stall
+ // the first request and eagerly SSR-load every server-function module).
+ // Builds import it so tree-shaking can't drop registrations for functions
+ // only client code references.
+ function handlerModuleCode(includeManifest: boolean): string {
+ // Server components ride the frame-stream wire protocol: the transform
+ // serves a function's component result as streamed HTML instead of data.
+ // Installing it here (config-level, merged with the other keys) covers
+ // both dispatch surfaces — the dev middleware and the prod handler load
+ // this module before dispatching — with zero per-request wiring. The
+ // import is only emitted when the option is on, so disabled setups keep
+ // a server-component-free graph.
+ return [
+ // The user's `configure` module comes first: a side-effect import in
+ // the handler graph, evaluated before any dispatch on both surfaces
+ // (dev middleware and prod handler) and bundled into the handler
+ // chunk by production builds. Order relative to the configure call
+ // below doesn't actually matter — runtime config merges per key —
+ // import-first is just the cleaner shape.
+ ...(configureModulePath ? [`import ${JSON.stringify(configureModulePath)};`] : []),
+ ...(includeManifest ? [`import ${JSON.stringify(manifestId)};`] : []),
+ `import { handleServerFunctionRequest as handle, configureServerFunctionsServer } from ${JSON.stringify(runtime.server)};`,
+ `import { provideRequestEvent } from ${JSON.stringify(STORAGE_SOURCE)};`,
+ ...(components
+ ? [
+ `import { frameTransformResult, frameTransformFlightResult, frameTransformDirectResult } from '@solidjs/web/frames';`,
+ ]
+ : []),
+ // `transformFlightResult` is the single-flight leg of the same wire
+ // protocol: a mutation whose invalidated payload includes markup gets
+ // the frame stream as its carrier (regions + envelope in one
+ // response). It only runs when a router registered a collectFlightData
+ // hook, so installing it unconditionally alongside the result
+ // transform costs disabled setups nothing.
+ //
+ // `transformDirectResult` is ALSO installed here — not just in the
+ // generated SSR entry — because flight collection makes direct
+ // (in-process) calls during handler dispatch, and the transform is what
+ // brands their results with the call address the client matches showing
+ // boundaries against. The SSR entry usually loads first and installs
+ // the same value (config merges per key), but the handler graph cannot
+ // depend on that: in dev, a mutation from an already-open page can be
+ // the first request after a server restart.
+ `configureServerFunctionsServer({ provideEvent: provideRequestEvent, endpoint: ${JSON.stringify(resolvedEndpoint)}${
+ components
+ ? ', transformResult: frameTransformResult, transformFlightResult: frameTransformFlightResult, transformDirectResult: frameTransformDirectResult'
+ : ''
+ } });`,
+ `export const endpoint = ${JSON.stringify(resolvedEndpoint)};`,
+ `export function handleServerFunctionRequest(request, options) {`,
+ ` return handle(request, { provideEvent: provideRequestEvent, ...options });`,
+ `}`,
+ ].join('\n');
+ }
+
+ // Function IDs are `xxHash32(root-relative path)-` (see compile.ts),
+ // so the hash segment maps an incoming ID back to its module. Rebuilt
+ // whenever a transform has grown the manifest.
+ const hashIndex = new Map();
+ let hashIndexSize = -1;
+ function moduleForFunctionId(functionId: string): string | undefined {
+ if (manifest.server.size !== hashIndexSize) {
+ hashIndex.clear();
+ for (const entry of manifest.server) {
+ const relative = path.relative(root, entry).split(path.sep).join('/');
+ hashIndex.set(xxHash32(relative).toString(16), entry);
+ }
+ hashIndexSize = manifest.server.size;
+ }
+ return hashIndex.get(functionId.split('-', 1)[0]!);
+ }
+
+ function moduleDevUrl(entry: string): string {
+ const relative = path.relative(root, entry).split(path.sep).join('/');
+ return relative.startsWith('..') ? '/@fs/' + entry : '/' + relative;
+ }
+
+ const turnkeyPlugins: Plugin[] = [
+ {
+ name: 'solid:server-functions/handler',
+ enforce: 'pre',
+ resolveId(source, _importer, opts) {
+ if (source === HANDLER_ID) {
+ if (!opts?.ssr) {
+ this.error(
+ `${HANDLER_ID} is server-only; import it from your server entry (SSR build).`,
+ );
+ }
+ return { id: HANDLER_ID, moduleSideEffects: true };
+ }
+ return null;
+ },
+ load(id, opts) {
+ if (id === HANDLER_ID && opts?.ssr) {
+ const externalDev =
+ this.environment.mode === 'dev' &&
+ (internal.externalDevServer || !isRunnableEnvironment(this.environment));
+ return handlerModuleCode(isBuild || externalDev);
+ }
+ return null;
+ },
+ },
+ ];
+
+ if (installDevMiddleware) {
+ turnkeyPlugins.push({
+ name: 'solid:server-functions/dev-middleware',
+ apply: 'serve',
+ configureServer(server) {
+ const ssrEnvironment = server.environments.ssr;
+ if (
+ internal.externalDevServer ||
+ (ssrEnvironment && !isRunnableEnvironment(ssrEnvironment))
+ ) {
+ return;
+ }
+ server.middlewares.use((req, res, next) => {
+ const url = new URL(req.url || '/', 'http://localhost');
+ // Match with and without `base` — middleware-mode hosts may mount
+ // vite.middlewares below the base themselves.
+ if (url.pathname !== resolvedEndpoint && url.pathname !== endpoint) {
+ return next();
+ }
+ // When the stripped form matched, restore the base for dispatch:
+ // the generated handler compares the request pathname against the
+ // base-prefixed endpoint, and production handlers only ever see
+ // base-prefixed URLs.
+ const dispatchUrl =
+ url.pathname === resolvedEndpoint ? undefined : joinBase(base, req.url || '/');
+ (async () => {
+ // Make sure the referenced module has been evaluated in the SSR
+ // environment so its registration exists — functions only client
+ // code references are never loaded by the SSR render itself.
+ const headerId = req.headers['x-server-function-id'];
+ const functionId =
+ (typeof headerId === 'string' ? headerId.split('#')[0] : undefined) ||
+ url.searchParams.get('id');
+ if (functionId) {
+ const entry = moduleForFunctionId(functionId);
+ if (entry) await server.ssrLoadModule(moduleDevUrl(entry));
+ }
+ // Dispatch through a module evaluated in the SSR environment so
+ // the handler shares the registry instance with the app modules.
+ // With SSR start mode active the main plugin threads its handler id
+ // in, and dispatch goes through `handleRequest` instead — one
+ // middleware chain and one stub-backed request event front the
+ // endpoint exactly as they front page SSR.
+ const handler = await server.ssrLoadModule(internal.ssrHandler ?? HANDLER_ID);
+ const response: Response = internal.ssrHandler
+ ? await handler.handleRequest(webRequestFromNode(req, dispatchUrl))
+ : await handler.handleServerFunctionRequest(webRequestFromNode(req, dispatchUrl));
+ await sendWebResponse(res, response);
+ })().catch((error) => {
+ if (error instanceof Error) server.ssrFixStacktrace(error);
+ next(error);
+ });
+ });
+ },
+ });
+ }
+
+ return [
+ {
+ name: 'solid:server-functions/setup',
+ enforce: 'pre',
+ configResolved(config) {
+ env = config.mode !== 'production' ? 'development' : 'production';
+ root = config.root;
+ base = config.base;
+ filter = createFilter(filterInclude, filterExclude, { resolve: root });
+ isBuild = config.command === 'build';
+ isSsrBuild = !!config.build.ssr;
+ outDir = config.build.outDir;
+ resolvedEndpoint = joinBase(config.base, endpoint);
+ if (options.configure) {
+ const absolute = path.isAbsolute(options.configure)
+ ? options.configure
+ : path.resolve(root, options.configure);
+ if (!existsSync(absolute)) {
+ throw new Error(
+ `[@solidjs/vite-plugin] serverFunctions.configure does not exist: ${options.configure}`,
+ );
+ }
+ configureModulePath = absolute;
+ }
+ if (isBuild && isSsrBuild) {
+ // Classic two-invocation build: pick up the modules the client
+ // build discovered so the server manifest registers them even when
+ // the SSR module graph never imports them.
+ for (const entry of readPersistedManifest(root)) {
+ manifest.server.add(entry);
+ }
+ }
+ },
+ configureServer(server) {
+ currentServer = server;
+ },
+ writeBundle() {
+ // Same client-build detection as the main plugin: builder-mode builds
+ // run both environments in one process, so prefer the per-environment
+ // consumer over the process-wide --ssr flag.
+ const ctx = this as { environment?: { config?: { consumer?: string } } };
+ const consumer = ctx.environment?.config?.consumer;
+ const isClient = consumer ? consumer === 'client' : !isSsrBuild;
+ if (isBuild && isClient) {
+ writePersistedManifest(root, outDir, manifest.server);
+ }
+ },
+ },
+ {
+ name: 'solid:server-functions/manifest',
+ enforce: 'pre',
+ resolveId(source) {
+ if (source === manifestId) {
+ return { id: manifestId, moduleSideEffects: true };
+ }
+ return null;
+ },
+ async load(id, opts) {
+ const mode = opts?.ssr ? 'server' : 'client';
+ if (id === manifestId) {
+ if (isBuild && mode === 'server') {
+ // Merge the client build's persisted discoveries at load time,
+ // not just configResolved: in builder mode (single process,
+ // `vite build` with the environments API) all environment
+ // configs resolve before the client build has written the file,
+ // but this load runs once the SSR environment builds — after it.
+ for (const entry of readPersistedManifest(root)) {
+ manifest.server.add(entry);
+ }
+ }
+ const current = new Debouncer(() =>
+ [...manifest[mode]].map((entry) => `import ${JSON.stringify(entry)};`).join('\n'),
+ );
+ preload[mode] = current;
+ const result = await current.promise.reference;
+ return result;
+ }
+ return null;
+ },
+ },
+ {
+ name: 'solid:server-functions/compiler',
+ enforce: 'pre',
+ async transform(code, fileId, opts) {
+ const mode = opts?.ssr ? 'server' : 'client';
+ const [id] = fileId.split('?');
+ if (!filter(id)) {
+ return null;
+ }
+
+ // Fast path: the directive has to appear literally, so anything
+ // without the substring can skip the native parse entirely.
+ if (!code.includes(directive)) {
+ return null;
+ }
+
+ const result = await compile(id!, code, {
+ ...(mode === 'server' ? serverOptions : clientOptions),
+ mode,
+ env,
+ root,
+ });
+
+ if (result.valid) {
+ const preloader = preload[mode];
+ if (preloader) {
+ preloader.defer();
+ }
+ invalidateModules(
+ currentServer,
+ mergeManifestRecord(manifest.server, new Set([id!])),
+ manifestId,
+ );
+
+ return {
+ // Appended (not prepended) so the source map for the compiled
+ // module stays valid; imports hoist and the endpoint is only
+ // read at call time, never during module evaluation.
+ code: (result.code || '') + endpointConfigureSnippet(mode),
+ map: result.map,
+ };
+ }
+ return null;
+ },
+ },
+ ...turnkeyPlugins,
+ ];
+}
diff --git a/src/server-functions/xxhash32.ts b/src/server-functions/xxhash32.ts
new file mode 100644
index 0000000..d4dfe62
--- /dev/null
+++ b/src/server-functions/xxhash32.ts
@@ -0,0 +1,114 @@
+// @ts-nocheck
+/**
+ * Hoisted from solid-start (packages/start/src/directives/xxhash32.ts).
+ *
+ * Copyright (c) 2019 Jason Dent
+ * https://github.com/Jason3S/xxhash
+ */
+const PRIME32_1 = 2654435761;
+const PRIME32_2 = 2246822519;
+const PRIME32_3 = 3266489917;
+const PRIME32_4 = 668265263;
+const PRIME32_5 = 374761393;
+
+function toUtf8(text: string): Uint8Array {
+ const bytes: number[] = [];
+ for (let i = 0, n = text.length; i < n; ++i) {
+ const c = text.charCodeAt(i);
+ if (c < 0x80) {
+ bytes.push(c);
+ } else if (c < 0x800) {
+ bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
+ } else if (c < 0xd800 || c >= 0xe000) {
+ bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
+ } else {
+ const cp = 0x10000 + (((c & 0x3ff) << 10) | (text.charCodeAt(++i) & 0x3ff));
+ bytes.push(
+ 0xf0 | ((cp >> 18) & 0x7),
+ 0x80 | ((cp >> 12) & 0x3f),
+ 0x80 | ((cp >> 6) & 0x3f),
+ 0x80 | (cp & 0x3f),
+ );
+ }
+ }
+ return new Uint8Array(bytes);
+}
+
+/**
+ * @param buffer - byte array or string
+ * @param seed - optional seed (32-bit unsigned)
+ */
+export default function xxHash32(buffer: Uint8Array | string, seed = 0): number {
+ buffer = typeof buffer === 'string' ? toUtf8(buffer) : buffer;
+ const b = buffer;
+
+ // Step 1. Initialize internal accumulators
+ let acc = (seed + PRIME32_5) & 0xffffffff;
+ let offset = 0;
+
+ if (b.length >= 16) {
+ const accN = [
+ (seed + PRIME32_1 + PRIME32_2) & 0xffffffff,
+ (seed + PRIME32_2) & 0xffffffff,
+ (seed + 0) & 0xffffffff,
+ (seed - PRIME32_1) & 0xffffffff,
+ ];
+
+ // Step 2. Process stripes (16 bytes = 4 lanes of 4 bytes)
+ const b = buffer;
+ const limit = b.length - 16;
+ let lane = 0;
+ for (offset = 0; (offset & 0xfffffff0) <= limit; offset += 4) {
+ const i = offset;
+ const laneN0 = b[i + 0] + (b[i + 1] << 8);
+ const laneN1 = b[i + 2] + (b[i + 3] << 8);
+ const laneNP = laneN0 * PRIME32_2 + ((laneN1 * PRIME32_2) << 16);
+ let acc = (accN[lane] + laneNP) & 0xffffffff;
+ acc = (acc << 13) | (acc >>> 19);
+ const acc0 = acc & 0xffff;
+ const acc1 = acc >>> 16;
+ accN[lane] = (acc0 * PRIME32_1 + ((acc1 * PRIME32_1) << 16)) & 0xffffffff;
+ lane = (lane + 1) & 0x3;
+ }
+
+ // Step 3. Accumulator convergence
+ acc =
+ (((accN[0] << 1) | (accN[0] >>> 31)) +
+ ((accN[1] << 7) | (accN[1] >>> 25)) +
+ ((accN[2] << 12) | (accN[2] >>> 20)) +
+ ((accN[3] << 18) | (accN[3] >>> 14))) &
+ 0xffffffff;
+ }
+
+ // Step 4. Add input length
+ acc = (acc + buffer.length) & 0xffffffff;
+
+ // Step 5. Consume remaining input (up to 15 bytes)
+ const limit = buffer.length - 4;
+ for (; offset <= limit; offset += 4) {
+ const i = offset;
+ const laneN0 = b[i + 0] + (b[i + 1] << 8);
+ const laneN1 = b[i + 2] + (b[i + 3] << 8);
+ const laneP = laneN0 * PRIME32_3 + ((laneN1 * PRIME32_3) << 16);
+ acc = (acc + laneP) & 0xffffffff;
+ acc = (acc << 17) | (acc >>> 15);
+ acc = ((acc & 0xffff) * PRIME32_4 + (((acc >>> 16) * PRIME32_4) << 16)) & 0xffffffff;
+ }
+
+ for (; offset < b.length; ++offset) {
+ const lane = b[offset];
+ acc += lane * PRIME32_5;
+ acc = (acc << 11) | (acc >>> 21);
+ acc = ((acc & 0xffff) * PRIME32_1 + (((acc >>> 16) * PRIME32_1) << 16)) & 0xffffffff;
+ }
+
+ // Step 6. Final mix (avalanche)
+ acc ^= acc >>> 15;
+ acc = (((acc & 0xffff) * PRIME32_2) & 0xffffffff) + (((acc >>> 16) * PRIME32_2) << 16);
+ acc ^= acc >>> 13;
+ acc = (((acc & 0xffff) * PRIME32_3) & 0xffffffff) + (((acc >>> 16) * PRIME32_3) << 16);
+ acc ^= acc >>> 16;
+
+ // turn any negatives back into a positive number;
+ return acc < 0 ? acc + 4294967296 : acc;
+}
diff --git a/src/ssr/index.ts b/src/ssr/index.ts
new file mode 100644
index 0000000..a730def
--- /dev/null
+++ b/src/ssr/index.ts
@@ -0,0 +1,1195 @@
+// Start-mode serving for plain Vite apps: `solid({ start: {...} })` (or the
+// zero-config sugar `start: true`) adds a serving layer with conventional
+// entries so no hand-rolled wiring is needed, and the plugin's `ssr`
+// boolean picks the mode — `ssr: true` server-renders the app per request;
+// `ssr: false`/omitted is client mode (the same conventions, but the
+// document shell is served/prerendered empty and the app `render()`s
+// client-side). The flip between them is that one boolean.
+//
+// SSR mode (`start` + `ssr: true`):
+// - Dev: runnable SSR environments are served by a Vite middleware. Provider-
+// owned environments serve through `virtual:solid-ssr-handler` instead.
+// Both paths inject the Vite client, dev style patch, and entry CSS as
+// `';`,
+ `}).join('');`,
+ ].join('\n');
+ }
+
+ function generatedEntryServerCode(): string {
+ if (clientMode) {
+ // The client-mode shell: the document without the app. Rendered per
+ // request in dev (any HTML GET gets it — history-fallback semantics)
+ // and once at build time into dist/client/index.html. The client
+ // entry script is injected by the handler, exactly like SSR mode.
+ return [
+ `import { renderToStream } from '@solidjs/web';`,
+ `import manifest from ${JSON.stringify(MANIFEST_ID)};`,
+ `import Document from ${JSON.stringify(documentSpec())};`,
+ ``,
+ `export function render(request, context) {`,
+ ` return renderToStream(() => , { manifest });`,
+ `}`,
+ ].join('\n');
+ }
+ const { app } = requireEntries();
+ const streamOptions = `{ manifest${serverComponents ? ', plugins: [ServerComponentPlugin]' : ''} }`;
+ return [
+ `import { renderToStream${setupPath ? ', getRequestEvent' : ''} } from '@solidjs/web';`,
+ ...(serverComponents
+ ? [
+ `import { configureServerFunctionsServer } from '@solidjs/web/server-functions';`,
+ `import { frameTransformDirectResult, ServerComponentPlugin } from '@solidjs/web/frames';`,
+ ]
+ : []),
+ `import manifest from ${JSON.stringify(MANIFEST_ID)};`,
+ `import Document from ${JSON.stringify(documentSpec())};`,
+ `import App from ${JSON.stringify(app)};`,
+ ...(setupPath ? [`import setup from ${JSON.stringify(setupPath)};`] : []),
+ ``,
+ ...(setupPath
+ ? [
+ `if (typeof setup !== 'function') {`,
+ ` throw new Error('[@solidjs/vite-plugin] start.setup must default-export a function ' +`,
+ ` '((event, App) => Component | void | Promise<...>): ' + ${JSON.stringify(options.setup)});`,
+ `}`,
+ ``,
+ ]
+ : []),
+ ...(serverComponents
+ ? [
+ // Direct (in-process) server-function calls made during document
+ // SSR must resolve to inline-renderable components; the endpoint
+ // response transform is installed separately by the
+ // server-function handler module (configure calls merge per key).
+ `configureServerFunctionsServer({ transformDirectResult: frameTransformDirectResult });`,
+ ``,
+ ]
+ : []),
+ ...(setupPath
+ ? [
+ // The per-request seam: the hook sees the same event the
+ // middleware chain decorated and finishes before renderToStream
+ // starts. When it is async, the stream must NOT cross the
+ // promise boundary bare — a promise resolving to a
+ // renderToStream result adopts its thenable (which waits for
+ // the *complete* render) and buffers the stream — so it crosses
+ // boxed under a private key the generated handler unboxes
+ // (both modules are ours).
+ `export function render(request, context) {`,
+ ` const prepared = setup(getRequestEvent(), App);`,
+ ` if (prepared && typeof prepared.then === 'function') {`,
+ ` return prepared.then((component) => ({ ${STREAM_BOX}: renderApp(component || App) }));`,
+ ` }`,
+ ` return renderApp(prepared || App);`,
+ `}`,
+ ``,
+ `function renderApp(Root) {`,
+ ` return renderToStream(() => (`,
+ ` `,
+ ` `,
+ ` `,
+ ` ), ${streamOptions});`,
+ `}`,
+ ]
+ : [
+ `export function render(request, context) {`,
+ ` return renderToStream(() => (`,
+ ` `,
+ ` `,
+ ` `,
+ ` ), ${streamOptions});`,
+ `}`,
+ ]),
+ ].join('\n');
+ }
+
+ function generatedEntryClientCode(): string {
+ const { app } = requireEntries();
+ if (clientMode) {
+ // render(), not hydrate(): the shell's body is empty, the app mounts
+ // fresh. Client code compiles non-hydratable in client mode, so the
+ // app cannot claim server DOM anyway. The entry script is injected
+ // without `async` (plain module = deferred), so document.body is
+ // complete when this runs.
+ return [
+ `import { render } from '@solidjs/web';`,
+ `import App from ${JSON.stringify(app)};`,
+ ``,
+ `render(() => , document.body);`,
+ ].join('\n');
+ }
+ return [
+ `import { hydrate } from '@solidjs/web';`,
+ ...(serverComponents
+ ? [`import { installServerComponents } from '@solidjs/web/frames';`]
+ : []),
+ `import Document from ${JSON.stringify(documentSpec())};`,
+ `import App from ${JSON.stringify(app)};`,
+ ``,
+ ...(serverComponents
+ ? [
+ // Installs the t=0 document-adoption registry and the transport
+ // policy (component responses morph their boundary instead of
+ // decoding as data). Must run before hydrate().
+ `installServerComponents();`,
+ ``,
+ ]
+ : []),
+ `hydrate(() => (`,
+ ` `,
+ ` `,
+ ` `,
+ `), document);`,
+ ].join('\n');
+ }
+
+ // Built-in document shell: minimal, hydration-ready. The client entry
+ // script is injected into by the handler (not rendered here) so its
+ // URL never has to survive hydration or a manifest lookup client-side.
+ // The client-mode variant drops — nothing hydrates,
+ // so the shell stays inert HTML. (A user-authored Document carrying
+ // HydrationScript is covered too: the handler strips the event-capture
+ // script from the client-mode shell.)
+ const documentShellCode = [
+ ...(clientMode ? [] : [`import { HydrationScript } from '@solidjs/web';`, ``]),
+ `export default function Document(props) {`,
+ ` return (`,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ...(clientMode ? [] : [` `]),
+ ` `,
+ ` {props.children}`,
+ ` `,
+ ` );`,
+ `}`,
+ ].join('\n');
+
+ // The handler module: dev and prod share the render/response plumbing;
+ // they differ in how the client entry URL is known (baked dev URL vs a
+ // manifest scan) and what gets injected into (Vite client + style
+ // patch in dev). The response-head lifecycle is the runtime's
+ // (`createRequestEvent`/`createSSRResponse`/`commitEventResponse` from
+ // @solidjs/web): every request runs under a stub-backed event,
+ // `httpStatus`/`httpHeader` writes land on the wire at shell flush, a
+ // pre-flush redirect becomes a real 3xx and a post-flush one the script
+ // fallback, and a Response that skipped the render lifecycle (middleware
+ // early return, raw entry.render Response, server functions) has the
+ // stub folded on at the handler edge after the middleware chain fully
+ // unwinds. When
+ // `serverFunctions` is enabled the endpoint is dispatched here on every
+ // surface (the runnable-dev middleware routes through this module), so
+ // user middleware and the shared request event front it identically.
+ function handlerModuleCode(externalDev: boolean): string {
+ const { generated, entryClient } = requireEntries();
+ const composeServerFunctions = internal.serverFunctions;
+
+ const lines = [
+ `import { createRequestEvent, createSSRResponse, commitEventResponse${middlewarePath ? ', composeMiddleware' : ''} } from '@solidjs/web';`,
+ `import { provideRequestEvent } from ${JSON.stringify(STORAGE_SOURCE)};`,
+ `import * as entry from ${JSON.stringify(entryServerSpec())};`,
+ ...(middlewarePath
+ ? [`import middlewareModule from ${JSON.stringify(middlewarePath)};`]
+ : []),
+ ...(externalDev ? [`import DEV_STYLES_HEAD from ${JSON.stringify(DEV_STYLES_ID)};`] : []),
+ ...(composeServerFunctions
+ ? [
+ `import { handleServerFunctionRequest, endpoint } from ${JSON.stringify(SERVER_FUNCTION_HANDLER_ID)};`,
+ ]
+ : []),
+ ];
+
+ if (isBuild) {
+ lines.push(`import manifest from ${JSON.stringify(MANIFEST_ID)};`);
+ lines.push(
+ ``,
+ `function joinAssetPath(base, file) {`,
+ ` if (typeof base !== 'string' || !base) base = '/';`,
+ ` if (base[base.length - 1] !== '/') base += '/';`,
+ ` return base + (file[0] === '/' ? file.slice(1) : file);`,
+ `}`,
+ ``,
+ `let clientEntryUrl;`,
+ `function resolveClientEntry() {`,
+ ` if (clientEntryUrl !== undefined) return clientEntryUrl;`,
+ ` clientEntryUrl = null;`,
+ // The plugin's manifest module normalizes lazy facade chunks
+ // (isDynamicEntry) so exactly one real entry remains flagged.
+ ` for (const key in manifest) {`,
+ ` const chunk = manifest[key];`,
+ ` if (chunk && chunk.isEntry && chunk.file) {`,
+ ` clientEntryUrl = joinAssetPath(manifest._base, chunk.file);`,
+ ` break;`,
+ ` }`,
+ ` }`,
+ ` return clientEntryUrl;`,
+ `}`,
+ );
+ } else {
+ const devHead =
+ `` +
+ ``;
+ lines.push(``, `const DEV_HEAD = ${JSON.stringify(devHead)};`);
+ }
+
+ // Middleware: the user module default-exports one fetch-style function
+ // or an array, composed in order. Without one, the chain degenerates to
+ // the terminal dispatch.
+ lines.push(``);
+ if (middlewarePath) {
+ lines.push(
+ `const middlewares = Array.isArray(middlewareModule) ? middlewareModule : [middlewareModule];`,
+ `for (const mw of middlewares) {`,
+ ` if (typeof mw !== 'function') {`,
+ ` throw new Error('[@solidjs/vite-plugin] start.middleware must default-export a function or an array of functions: ' + ${JSON.stringify(middlewarePath)});`,
+ ` }`,
+ `}`,
+ `const runMiddleware = composeMiddleware(middlewares);`,
+ );
+ } else {
+ lines.push(`const runMiddleware = (request, next) => next(request);`);
+ }
+
+ // No `_$SC` bootstrap injection: the runtime's serialized
+ // server-component references self-bootstrap the registry (each
+ // hydration script's first reference carries it as an idempotent
+ // expression), so nothing needs to precede the data scripts. The old
+ // head-open splice actively broke hydration — a script ahead of the
+ // authored elements claims as the first walked child and drifts
+ // every positional claim after it.
+ lines.push(
+ ``,
+ `function createHtmlChunkTransform(clientEntry, extraHead) {`,
+ ` let first = true;`,
+ ` let injected = false;`,
+ ` return (chunk) => {`,
+ );
+ if (!generated) {
+ // Authored entries reference the client entry by its dev path (the
+ // `