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 + // `