diff --git a/.changeset/fix-2068-server-only-guard.md b/.changeset/fix-2068-server-only-guard.md new file mode 100644 index 000000000..895268b8c --- /dev/null +++ b/.changeset/fix-2068-server-only-guard.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +fix: mark `@solidjs/start/http` and `@solidjs/start/middleware` as `server-only` so importing them from client-reachable code fails loudly at dev/build time, instead of silently shipping them to the browser where they crashed hydration and broke unrelated actions/forms with no diagnostic (#2068) diff --git a/packages/start/src/config/boundary-modules.spec.ts b/packages/start/src/config/boundary-modules.spec.ts new file mode 100644 index 000000000..830eb99a9 --- /dev/null +++ b/packages/start/src/config/boundary-modules.spec.ts @@ -0,0 +1,50 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { boundaryModules } from "./boundary-modules.ts"; + +function resolveWith(id: string, ssr: boolean) { + const plugin = boundaryModules() as any; + const context = { + error(message: string): never { + throw new Error(message); + }, + }; + return () => plugin.resolveId.call(context, id, "/src/lib/someClient.ts", { ssr }); +} + +describe("boundaryModules", () => { + it("fails when a client module imports server-only", () => { + expect(resolveWith("server-only", false)).toThrowError(/server-only.*someClient\.ts/s); + }); + + it("fails when a server module imports client-only", () => { + expect(resolveWith("client-only", true)).toThrowError(/client-only.*someClient\.ts/s); + }); + + it("resolves the markers to an empty module in the allowed environment", () => { + const plugin = boundaryModules() as any; + const serverOnlyId = resolveWith("server-only", true)(); + const clientOnlyId = resolveWith("client-only", false)(); + expect(plugin.load(serverOnlyId)).toBe("export {}"); + expect(plugin.load(clientOnlyId)).toBe("export {}"); + }); + + it("ignores unrelated ids", () => { + expect(resolveWith("solid-js", false)()).toBe(null); + expect(resolveWith("./local-module.ts", false)()).toBe(null); + }); + + // Regression guard for #2068: Start's server-only entry points must carry + // the marker so client-reachable imports of them fail loudly instead of + // shipping server code to the browser and crashing hydration. + it("marks Start's server-only entry points with server-only", () => { + for (const entry of ["http/index.ts", "middleware/index.ts"]) { + const source = readFileSync(join(import.meta.dirname, "..", entry), "utf-8"); + expect(source, `src/${entry} must import "server-only"`).toMatch( + /^import "server-only";$/m, + ); + } + }); +}); diff --git a/packages/start/src/config/boundary-modules.ts b/packages/start/src/config/boundary-modules.ts new file mode 100644 index 000000000..2325c2e1f --- /dev/null +++ b/packages/start/src/config/boundary-modules.ts @@ -0,0 +1,45 @@ +import type { Plugin } from "vite"; + +const VIRTUAL_ID = "\0solid-start:boundary-modules:id"; + +/** + * Supports `server-only` and `client-only` marker modules (#2162): importing + * `server-only` from a client module (or `client-only` from a server module) + * fails at resolve time; in the allowed environment the marker resolves to an + * empty module. + * + * Start's own server-only entry points (`@solidjs/start/http`, + * `@solidjs/start/middleware`) import `server-only` themselves, so pulling + * them into the client bundle fails loudly instead of shipping server code to + * the browser, where it crashed hydration and broke unrelated actions/forms + * with no diagnostic (https://github.com/solidjs/solid-start/issues/2068). + */ +export function boundaryModules(): Plugin { + return { + name: "solid-start:boundary-modules", + enforce: "pre", + resolveId(id, importer, { ssr }) { + if (id === "server-only") { + if (!ssr) + this.error( + `Attempt to import 'server-only' in a client module: ${importer}. ` + + `Code that uses this module must run only on the server: mark it with ` + + `"use server", or make sure it is only imported by server code.`, + ); + } else if (id === "client-only") { + if (ssr) + this.error( + `Attempt to import 'client-only' in a server module: ${importer}. ` + + `Code that uses this module must run only in the browser: make sure it ` + + `is only imported by client code (e.g. wrap components with clientOnly()).`, + ); + } else { + return null; + } + return VIRTUAL_ID; + }, + load(id) { + if (id === VIRTUAL_ID) return "export {}"; + }, + }; +} diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index c5cdb886a..bfed84ffd 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -4,6 +4,7 @@ import { extname, isAbsolute, join } from "node:path"; import type { PluginOption } from "vite"; import solid, { type Options as SolidOptions } from "vite-plugin-solid"; import { type ServerFunctionsOptions, serverFunctionsPlugin } from "../directives/index.ts"; +import { boundaryModules } from "./boundary-modules.ts"; import { DEFAULT_EXTENSIONS, VIRTUAL_MODULES, VITE_ENVIRONMENTS } from "./constants.ts"; import { devServer } from "./dev-server.ts"; import { envPlugin, type EnvPluginOptions } from "./env.ts"; @@ -209,6 +210,7 @@ export function solidStart(options?: SolidStartOptions): Array { }), lazy(), envPlugin(options?.env), + boundaryModules(), { name: "solid-start:boundary-modules", enforce: "pre", diff --git a/packages/start/src/http/http.ts b/packages/start/src/http/http.ts new file mode 100644 index 000000000..0b4339929 --- /dev/null +++ b/packages/start/src/http/http.ts @@ -0,0 +1,216 @@ +import type { H3Event, HTTPEvent, InferEventInput } from "h3"; +import * as h3 from "h3"; +import { getRequestEvent } from "solid-js/web"; + +function _setContext(event: H3Event, key: string, value: any) { + event.context[key] = value; +} + +function _getContext(event: H3Event, key: string) { + return event.context[key]; +} + +function getEvent() { + return getRequestEvent()!.nativeEvent; +} + +export function getWebRequest(): Request { + return getEvent().req; +} + +export const HTTPEventSymbol = Symbol("$HTTPEvent"); + +export function isEvent(obj: any): obj is h3.H3Event | { [HTTPEventSymbol]: h3.H3Event } { + return ( + typeof obj === "object" && + (obj instanceof h3.H3Event || + obj?.[HTTPEventSymbol] instanceof h3.H3Event || + obj?.__is_event__ === true) + ); + // Implement logic to check if obj is an H3Event +} + +type Tail = T extends [any, ...infer U] ? U : never; + +type PrependOverload< + TOriginal extends (...args: Array) => any, + TOverload extends (...args: Array) => any, +> = TOverload & TOriginal; + +// add an overload to the function without the event argument +type WrapFunction) => any> = PrependOverload< + TFn, + ( + ...args: Parameters extends [h3.HTTPEvent | h3.H3Event, ...infer TArgs] + ? TArgs + : Parameters + ) => ReturnType +>; + +function createWrapperFunction) => any>( + h3Function: TFn, +): WrapFunction { + return ((...args: Array) => { + const event = args[0]; + if (!isEvent(event)) { + args.unshift(getEvent()); + } else { + args[0] = + event instanceof h3.H3Event || (event as any).__is_event__ ? event : event[HTTPEventSymbol]; + } + + return (h3Function as any)(...args); + }) as any; +} + +// Creating wrappers for each utility and exporting them with their original names +// readRawBody => getWebRequest().text()/.arrayBuffer() +type WrappedReadBody = >( + ...args: Tail>> +) => ReturnType>; +export const readBody = createWrapperFunction(h3.readBody) as PrependOverload< + typeof h3.readBody, + WrappedReadBody +>; +type WrappedGetQuery = , undefined>>( + ...args: Tail>> +) => ReturnType>; +export const getQuery = createWrapperFunction(h3.getQuery) as PrependOverload< + typeof h3.getQuery, + WrappedGetQuery +>; +export const isMethod = createWrapperFunction(h3.isMethod); +export const isPreflightRequest = createWrapperFunction(h3.isPreflightRequest); +type WrappedGetValidatedQuery = < + T extends HTTPEvent, + TEventInput = InferEventInput<"query", H3Event, T>, +>( + ...args: Tail>> +) => ReturnType>; +export const getValidatedQuery = createWrapperFunction(h3.getValidatedQuery) as PrependOverload< + typeof h3.getValidatedQuery, + WrappedGetValidatedQuery +>; +export const getRouterParams = createWrapperFunction(h3.getRouterParams); +export const getRouterParam = createWrapperFunction(h3.getRouterParam); +type WrappedGetValidatedRouterParams = < + T extends HTTPEvent, + TEventInput = InferEventInput<"routerParams", H3Event, T>, +>( + ...args: Tail>> +) => ReturnType>; +export const getValidatedRouterParams = createWrapperFunction( + h3.getValidatedRouterParams, +) as PrependOverload; +export const assertMethod = createWrapperFunction(h3.assertMethod); +export const getRequestHeaders = createWrapperFunction(h3.getRequestHeaders); +export const getRequestHeader = createWrapperFunction(h3.getRequestHeader); +export const getRequestURL = createWrapperFunction(h3.getRequestURL); +export const getRequestHost = createWrapperFunction(h3.getRequestHost); +export const getRequestProtocol = createWrapperFunction(h3.getRequestProtocol); +export const getRequestIP = createWrapperFunction(h3.getRequestIP); +export const setResponseStatus = (code?: number, text?: string) => { + const e = getEvent(); + + if (e.res.status !== undefined) e.res.status = code; + if (e.res.statusText !== undefined) e.res.statusText = text; +}; +export const getResponseStatus = () => getEvent().res.status; +export const getResponseStatusText = () => getEvent().res.statusText; +export const getResponseHeaders = () => Object.fromEntries(getEvent().res.headers.entries()); +export const getResponseHeader = (name: string) => getEvent().res.headers.get(name); +export const setResponseHeaders = (values: Record) => { + const headers = getEvent().res.headers; + for (const [name, value] of Object.entries(values)) { + headers.set(name, value); + } +}; +export const setResponseHeader = (name: string, value: string | string[]) => { + const headers = getEvent().res.headers; + + (Array.isArray(value) ? value : [value]).forEach(value => { + headers.set(name, value); + }); +}; +export const appendResponseHeaders = (values: Record) => { + const headers = getEvent().res.headers; + for (const [name, value] of Object.entries(values)) { + headers.append(name, value); + } +}; +export const appendResponseHeader = (name: string, value: string | string[]) => { + const headers = getEvent().res.headers; + + (Array.isArray(value) ? value : [value]).forEach(value => { + headers.append(name, value); + }); +}; +export const defaultContentType = (type: string) => + getEvent().res.headers.set("content-type", type); +export const proxyRequest = createWrapperFunction(h3.proxyRequest); +export const fetchWithEvent = createWrapperFunction(h3.fetchWithEvent); +export const getProxyRequestHeaders = createWrapperFunction(h3.getProxyRequestHeaders); + +export const parseCookies = createWrapperFunction(h3.parseCookies); +export const getCookie = createWrapperFunction(h3.getCookie); +export const setCookie = createWrapperFunction(h3.setCookie); +export const deleteCookie = createWrapperFunction(h3.deleteCookie); +// not exported :( +type SessionDataT = Record; +type WrappedUseSession = ( + ...args: Tail>> +) => ReturnType>; +export const useSession: WrappedUseSession = createWrapperFunction(h3.useSession); +type WrappedGetSession = ( + ...args: Tail>> +) => ReturnType>; +export const getSession: WrappedGetSession = createWrapperFunction(h3.getSession); +type WrappedUpdateSession = ( + ...args: Tail>> +) => ReturnType>; +export const updateSession: WrappedUpdateSession = createWrapperFunction(h3.updateSession); +type WrappedSealSession = ( + ...args: Tail>> +) => ReturnType>; +export const sealSession: WrappedSealSession = createWrapperFunction(h3.sealSession); +export const unsealSession = createWrapperFunction(h3.unsealSession); +export const clearSession = createWrapperFunction(h3.clearSession); +export const handleCacheHeaders = createWrapperFunction(h3.handleCacheHeaders); +export const handleCors = createWrapperFunction(h3.handleCors); +export const appendCorsHeaders = createWrapperFunction(h3.appendCorsHeaders); +export const appendCorsPreflightHeaders = createWrapperFunction(h3.appendCorsPreflightHeaders); +export const appendHeader = appendResponseHeader; +export const appendHeaders = appendResponseHeaders; +export const setHeader = setResponseHeader; +export const setHeaders = setResponseHeaders; +export const getHeader = getRequestHeader; +export const getHeaders = getRequestHeaders; +export const getRequestFingerprint = createWrapperFunction(h3.getRequestFingerprint); +export const getRequestWebStream = () => getEvent().req.body; +export const readFormData = () => getEvent().req.formData(); +type WrappedReadValidatedBody = < + T extends HTTPEvent, + TEventInput = InferEventInput<"body", H3Event, T>, +>( + ...args: Tail>> +) => ReturnType>; +export const readValidatedBody = createWrapperFunction(h3.readValidatedBody) as PrependOverload< + typeof h3.readValidatedBody, + WrappedReadValidatedBody +>; +export const getContext = createWrapperFunction(_getContext); +export const setContext = createWrapperFunction(_setContext); +export const removeResponseHeader = (name: string) => getEvent().res.headers.delete(name); +export const clearResponseHeaders = (headerNames?: string[]) => { + const headers = getEvent().res.headers; + + if (headerNames && headerNames.length > 0) { + for (const name of headerNames) { + headers.delete(name); + } + } else { + for (const name of headers.keys()) { + headers.delete(name); + } + } +}; diff --git a/packages/start/src/http/index.ts b/packages/start/src/http/index.ts index 0b4339929..e1aaa1e15 100644 --- a/packages/start/src/http/index.ts +++ b/packages/start/src/http/index.ts @@ -1,216 +1,7 @@ -import type { H3Event, HTTPEvent, InferEventInput } from "h3"; -import * as h3 from "h3"; -import { getRequestEvent } from "solid-js/web"; +// The public `@solidjs/start/http` entry is server-only: importing it from +// client-reachable code fails at resolve time (#2068). Internal isomorphic +// code (e.g. ``) imports `./http.ts` directly, since it only +// touches these helpers behind an `isServer` check. +import "server-only"; -function _setContext(event: H3Event, key: string, value: any) { - event.context[key] = value; -} - -function _getContext(event: H3Event, key: string) { - return event.context[key]; -} - -function getEvent() { - return getRequestEvent()!.nativeEvent; -} - -export function getWebRequest(): Request { - return getEvent().req; -} - -export const HTTPEventSymbol = Symbol("$HTTPEvent"); - -export function isEvent(obj: any): obj is h3.H3Event | { [HTTPEventSymbol]: h3.H3Event } { - return ( - typeof obj === "object" && - (obj instanceof h3.H3Event || - obj?.[HTTPEventSymbol] instanceof h3.H3Event || - obj?.__is_event__ === true) - ); - // Implement logic to check if obj is an H3Event -} - -type Tail = T extends [any, ...infer U] ? U : never; - -type PrependOverload< - TOriginal extends (...args: Array) => any, - TOverload extends (...args: Array) => any, -> = TOverload & TOriginal; - -// add an overload to the function without the event argument -type WrapFunction) => any> = PrependOverload< - TFn, - ( - ...args: Parameters extends [h3.HTTPEvent | h3.H3Event, ...infer TArgs] - ? TArgs - : Parameters - ) => ReturnType ->; - -function createWrapperFunction) => any>( - h3Function: TFn, -): WrapFunction { - return ((...args: Array) => { - const event = args[0]; - if (!isEvent(event)) { - args.unshift(getEvent()); - } else { - args[0] = - event instanceof h3.H3Event || (event as any).__is_event__ ? event : event[HTTPEventSymbol]; - } - - return (h3Function as any)(...args); - }) as any; -} - -// Creating wrappers for each utility and exporting them with their original names -// readRawBody => getWebRequest().text()/.arrayBuffer() -type WrappedReadBody = >( - ...args: Tail>> -) => ReturnType>; -export const readBody = createWrapperFunction(h3.readBody) as PrependOverload< - typeof h3.readBody, - WrappedReadBody ->; -type WrappedGetQuery = , undefined>>( - ...args: Tail>> -) => ReturnType>; -export const getQuery = createWrapperFunction(h3.getQuery) as PrependOverload< - typeof h3.getQuery, - WrappedGetQuery ->; -export const isMethod = createWrapperFunction(h3.isMethod); -export const isPreflightRequest = createWrapperFunction(h3.isPreflightRequest); -type WrappedGetValidatedQuery = < - T extends HTTPEvent, - TEventInput = InferEventInput<"query", H3Event, T>, ->( - ...args: Tail>> -) => ReturnType>; -export const getValidatedQuery = createWrapperFunction(h3.getValidatedQuery) as PrependOverload< - typeof h3.getValidatedQuery, - WrappedGetValidatedQuery ->; -export const getRouterParams = createWrapperFunction(h3.getRouterParams); -export const getRouterParam = createWrapperFunction(h3.getRouterParam); -type WrappedGetValidatedRouterParams = < - T extends HTTPEvent, - TEventInput = InferEventInput<"routerParams", H3Event, T>, ->( - ...args: Tail>> -) => ReturnType>; -export const getValidatedRouterParams = createWrapperFunction( - h3.getValidatedRouterParams, -) as PrependOverload; -export const assertMethod = createWrapperFunction(h3.assertMethod); -export const getRequestHeaders = createWrapperFunction(h3.getRequestHeaders); -export const getRequestHeader = createWrapperFunction(h3.getRequestHeader); -export const getRequestURL = createWrapperFunction(h3.getRequestURL); -export const getRequestHost = createWrapperFunction(h3.getRequestHost); -export const getRequestProtocol = createWrapperFunction(h3.getRequestProtocol); -export const getRequestIP = createWrapperFunction(h3.getRequestIP); -export const setResponseStatus = (code?: number, text?: string) => { - const e = getEvent(); - - if (e.res.status !== undefined) e.res.status = code; - if (e.res.statusText !== undefined) e.res.statusText = text; -}; -export const getResponseStatus = () => getEvent().res.status; -export const getResponseStatusText = () => getEvent().res.statusText; -export const getResponseHeaders = () => Object.fromEntries(getEvent().res.headers.entries()); -export const getResponseHeader = (name: string) => getEvent().res.headers.get(name); -export const setResponseHeaders = (values: Record) => { - const headers = getEvent().res.headers; - for (const [name, value] of Object.entries(values)) { - headers.set(name, value); - } -}; -export const setResponseHeader = (name: string, value: string | string[]) => { - const headers = getEvent().res.headers; - - (Array.isArray(value) ? value : [value]).forEach(value => { - headers.set(name, value); - }); -}; -export const appendResponseHeaders = (values: Record) => { - const headers = getEvent().res.headers; - for (const [name, value] of Object.entries(values)) { - headers.append(name, value); - } -}; -export const appendResponseHeader = (name: string, value: string | string[]) => { - const headers = getEvent().res.headers; - - (Array.isArray(value) ? value : [value]).forEach(value => { - headers.append(name, value); - }); -}; -export const defaultContentType = (type: string) => - getEvent().res.headers.set("content-type", type); -export const proxyRequest = createWrapperFunction(h3.proxyRequest); -export const fetchWithEvent = createWrapperFunction(h3.fetchWithEvent); -export const getProxyRequestHeaders = createWrapperFunction(h3.getProxyRequestHeaders); - -export const parseCookies = createWrapperFunction(h3.parseCookies); -export const getCookie = createWrapperFunction(h3.getCookie); -export const setCookie = createWrapperFunction(h3.setCookie); -export const deleteCookie = createWrapperFunction(h3.deleteCookie); -// not exported :( -type SessionDataT = Record; -type WrappedUseSession = ( - ...args: Tail>> -) => ReturnType>; -export const useSession: WrappedUseSession = createWrapperFunction(h3.useSession); -type WrappedGetSession = ( - ...args: Tail>> -) => ReturnType>; -export const getSession: WrappedGetSession = createWrapperFunction(h3.getSession); -type WrappedUpdateSession = ( - ...args: Tail>> -) => ReturnType>; -export const updateSession: WrappedUpdateSession = createWrapperFunction(h3.updateSession); -type WrappedSealSession = ( - ...args: Tail>> -) => ReturnType>; -export const sealSession: WrappedSealSession = createWrapperFunction(h3.sealSession); -export const unsealSession = createWrapperFunction(h3.unsealSession); -export const clearSession = createWrapperFunction(h3.clearSession); -export const handleCacheHeaders = createWrapperFunction(h3.handleCacheHeaders); -export const handleCors = createWrapperFunction(h3.handleCors); -export const appendCorsHeaders = createWrapperFunction(h3.appendCorsHeaders); -export const appendCorsPreflightHeaders = createWrapperFunction(h3.appendCorsPreflightHeaders); -export const appendHeader = appendResponseHeader; -export const appendHeaders = appendResponseHeaders; -export const setHeader = setResponseHeader; -export const setHeaders = setResponseHeaders; -export const getHeader = getRequestHeader; -export const getHeaders = getRequestHeaders; -export const getRequestFingerprint = createWrapperFunction(h3.getRequestFingerprint); -export const getRequestWebStream = () => getEvent().req.body; -export const readFormData = () => getEvent().req.formData(); -type WrappedReadValidatedBody = < - T extends HTTPEvent, - TEventInput = InferEventInput<"body", H3Event, T>, ->( - ...args: Tail>> -) => ReturnType>; -export const readValidatedBody = createWrapperFunction(h3.readValidatedBody) as PrependOverload< - typeof h3.readValidatedBody, - WrappedReadValidatedBody ->; -export const getContext = createWrapperFunction(_getContext); -export const setContext = createWrapperFunction(_setContext); -export const removeResponseHeader = (name: string) => getEvent().res.headers.delete(name); -export const clearResponseHeaders = (headerNames?: string[]) => { - const headers = getEvent().res.headers; - - if (headerNames && headerNames.length > 0) { - for (const name of headerNames) { - headers.delete(name); - } - } else { - for (const name of headers.keys()) { - headers.delete(name); - } - } -}; +export * from "./http.ts"; diff --git a/packages/start/src/middleware/index.ts b/packages/start/src/middleware/index.ts index 19f068274..6ef93f7ea 100644 --- a/packages/start/src/middleware/index.ts +++ b/packages/start/src/middleware/index.ts @@ -1,5 +1,7 @@ // @refresh skip +import "server-only"; + import type { H3Event, Middleware } from "h3"; import { getFetchEvent } from "../server/fetchEvent.ts"; import type { FetchEvent } from "../server/types.ts"; diff --git a/packages/start/src/shared/HttpHeader.tsx b/packages/start/src/shared/HttpHeader.tsx index 6dcb504b6..d69401676 100644 --- a/packages/start/src/shared/HttpHeader.tsx +++ b/packages/start/src/shared/HttpHeader.tsx @@ -3,7 +3,7 @@ import { onCleanup } from "solid-js"; import { getRequestEvent, isServer } from "solid-js/web"; import type { PageEvent } from "../server/types.ts"; -import { appendHeader, setHeader } from "../http/index.ts"; +import { appendHeader, setHeader } from "../http/http.ts"; export interface HttpHeaderProps { name: string;