From d977d8208b270ce03f0266b016df588c8559b086 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Sun, 26 Jul 2026 17:02:39 +0200 Subject: [PATCH] feat: include server function id in the request URL --- .changeset/khaki-forks-shave.md | 25 +++++++++++++ packages/start/src/config/index.ts | 6 ++-- packages/start/src/directives/index.ts | 8 +++++ packages/start/src/directives/plugin.ts | 7 +++- packages/start/src/fns/client.ts | 13 ++++--- packages/start/src/fns/handler.ts | 4 ++- packages/start/src/fns/server.ts | 6 ++-- packages/start/src/fns/url.spec.ts | 48 +++++++++++++++++++++++++ packages/start/src/fns/url.ts | 38 ++++++++++++++++++++ packages/start/src/server/handler.ts | 5 ++- 10 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 .changeset/khaki-forks-shave.md create mode 100644 packages/start/src/fns/url.spec.ts create mode 100644 packages/start/src/fns/url.ts diff --git a/.changeset/khaki-forks-shave.md b/.changeset/khaki-forks-shave.md new file mode 100644 index 000000000..24250fc03 --- /dev/null +++ b/.changeset/khaki-forks-shave.md @@ -0,0 +1,25 @@ +--- +"@solidjs/start": minor +--- + +Include the server function id in its request URL + +Server function calls now go to `_server/` instead of a bare `_server`, so +access logs, traces and the network panel can tell functions apart instead of +collapsing every call into one entry. Ids contain the function's source name in +development, e.g. `POST /_server/a1b2c3-0-getUser`. + +Production ids stay opaque by default. Set `serverFunctions.readableIds` to keep +the names in production builds too: + +```js +import { solidStart } from "@solidjs/start/config"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [solidStart({ serverFunctions: { readableIds: true } })], +}); +``` + +Requests to the previous `_server?id=` URL are still handled, so bookmarked +or hand-written URLs keep working. diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 4ec6ab107..d4e974df7 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -116,9 +116,10 @@ export interface SolidStartOptions { /** * Options controlling which files are processed as server functions - * (inclusion / exclusion filters for the `"use server"` transform). + * (inclusion / exclusion filters for the `"use server"` transform) and how + * their ids are generated. */ - serverFunctions?: Pick; + serverFunctions?: Pick; } const absolute = (path: string, root: string) => @@ -160,6 +161,7 @@ export function solidStart(options?: SolidStartOptions): Array { client: "@solidjs/start/fns/client", }, filter: options?.serverFunctions?.filter, + readableIds: options?.serverFunctions?.readableIds, }), { name: "solid-start:config", diff --git a/packages/start/src/directives/index.ts b/packages/start/src/directives/index.ts index 40273b4f8..f39fb0a80 100644 --- a/packages/start/src/directives/index.ts +++ b/packages/start/src/directives/index.ts @@ -21,6 +21,13 @@ export interface ServerFunctionsOptions { client: string; }; filter?: ServerFunctionsFilter; + /** + * Include the function name in generated server function ids for production + * builds. Ids are the last segment of the `_server/` request URL, so this + * makes production logs and traces readable at the cost of exposing the + * source name of each server function. Always on in development. + */ + readableIds?: boolean; } const DEFAULT_INCLUDE = "src/**/*.{jsx,tsx,ts,js,mjs,cjs}"; @@ -240,6 +247,7 @@ export function serverFunctionsPlugin(options: ServerFunctionsOptions): Plugin[] ...(mode === "server" ? serverOptions : clientOptions), mode, env, + readableIds: options.readableIds, }); if (result.valid) { diff --git a/packages/start/src/directives/plugin.ts b/packages/start/src/directives/plugin.ts index 20299c294..ad4b85e71 100644 --- a/packages/start/src/directives/plugin.ts +++ b/packages/start/src/directives/plugin.ts @@ -19,6 +19,8 @@ export interface StateContext { count: number; imports: Map; valid: boolean; + /** Keep the function name in the generated id in production builds too. */ + readableIds?: boolean; definitions: { register: ImportDefinition; @@ -78,7 +80,10 @@ function isFunctionDirectiveValid( function createID(ctx: StateContext, name: string) { const base = `${ctx.hash}-${ctx.count++}`; - if (ctx.env === "development") { + // The id is the last segment of the `_server/` request URL, so including + // the source name keeps logs and traces readable. Production ids stay opaque + // unless the app opts in, since the name is then exposed to the client. + if (ctx.env === "development" || ctx.readableIds) { return `${base}-${name}`; } return base; diff --git a/packages/start/src/fns/client.ts b/packages/start/src/fns/client.ts index 690bc811d..13f7ba90c 100644 --- a/packages/start/src/fns/client.ts +++ b/packages/start/src/fns/client.ts @@ -5,6 +5,7 @@ import { serializeToJSONString, } from "./serialization.ts"; import { BODY_FORMAT_KEY, BodyFormat, extractBody, getHeadersAndBody } from "./shared.ts"; +import { serverFunctionURL } from "./url.ts"; let INSTANCE = 0; @@ -102,21 +103,19 @@ async function fetchServerFunction( } export function cloneServerReference(id: string) { - let baseURL = import.meta.env.BASE_URL ?? "/"; - if (!baseURL.endsWith("/")) baseURL += "/"; + const url = serverFunctionURL(id); - const fn = (...args: any[]) => fetchServerFunction(`${baseURL}_server`, id, {}, args); + const fn = (...args: any[]) => fetchServerFunction(url, id, {}, args); return new Proxy(fn, { get(target, prop, receiver) { if (prop === "url") { - return `${baseURL}_server?id=${encodeURIComponent(id)}`; + return url; } if (prop === "GET") { return receiver.withOptions({ method: "GET" }); } if (prop === "withOptions") { - const url = `${baseURL}_server?id=${encodeURIComponent(id)}`; return (options: RequestInit) => { const fn = async (...args: any[]) => { const encodeArgs = options.method && options.method.toUpperCase() === "GET"; @@ -124,9 +123,9 @@ export function cloneServerReference(id: string) { encodeArgs ? url + (args.length - ? `&args=${encodeURIComponent(await serializeToJSONString(args))}` + ? `?args=${encodeURIComponent(await serializeToJSONString(args))}` : "") - : `${baseURL}_server`, + : url, id, options, encodeArgs ? [] : args, diff --git a/packages/start/src/fns/handler.ts b/packages/start/src/fns/handler.ts index 7c308edb2..4bd6f3103 100644 --- a/packages/start/src/fns/handler.ts +++ b/packages/start/src/fns/handler.ts @@ -15,6 +15,7 @@ import { BODY_FORMAT_KEY, BodyFormat, extractBody, getHeadersAndBody } from "./s import "solid-start:server-fn-manifest"; import { getServerFunction, hasServerFunction } from "./registration.ts"; +import { getFunctionIdFromPath } from "./url.ts"; import type { FetchEvent, PageEvent } from "../server/types.ts"; import { getExpectedRedirectStatus } from "../server/util.ts"; @@ -31,7 +32,8 @@ export async function handleServerFunction(h3Event: H3Event) { // invariant(typeof serverReference === "string", "Invalid server function"); [functionId] = serverReference.split("#"); } else { - functionId = url.searchParams.get("id"); + // `?id=` is still honoured for hand-written URLs predating `_server/` + functionId = getFunctionIdFromPath(url.pathname) ?? url.searchParams.get("id"); if (!functionId) { return process.env.NODE_ENV === "development" diff --git a/packages/start/src/fns/server.ts b/packages/start/src/fns/server.ts index de6ecd8a8..fee3f1478 100644 --- a/packages/start/src/fns/server.ts +++ b/packages/start/src/fns/server.ts @@ -1,6 +1,7 @@ import { getRequestEvent } from "solid-js/web"; import { provideRequestEvent } from "solid-js/web/storage"; import { registerServerFunction } from "./registration.ts"; +import { serverFunctionURL } from "./url.ts"; interface Registration { id: string; @@ -19,13 +20,12 @@ export function createServerReference( export function cloneServerReference({ id, fn }: Registration) { if (typeof fn !== "function") throw new Error("Export from a 'use server' module must be a function"); - let baseURL = import.meta.env.BASE_URL ?? "/"; - if (!baseURL.endsWith("/")) baseURL += "/"; + const url = serverFunctionURL(id); return new Proxy(fn, { get(target, prop, receiver) { if (prop === "url") { - return `${baseURL}_server?id=${encodeURIComponent(id)}`; + return url; } if (prop === "GET") return receiver; return (target as any)[prop]; diff --git a/packages/start/src/fns/url.spec.ts b/packages/start/src/fns/url.spec.ts new file mode 100644 index 000000000..9cc4afa76 --- /dev/null +++ b/packages/start/src/fns/url.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { getFunctionIdFromPath, isServerFunctionPath, serverFunctionURL } from "./url.ts"; + +describe("serverFunctionURL", () => { + it("puts the function id in the path", () => { + expect(serverFunctionURL("a1b2c3-0-getUser")).toBe("/_server/a1b2c3-0-getUser"); + }); + + it("encodes ids", () => { + expect(serverFunctionURL("a1b2c3-0-a b")).toBe("/_server/a1b2c3-0-a%20b"); + }); +}); + +describe("isServerFunctionPath", () => { + it("matches the bare base and ids below it", () => { + expect(isServerFunctionPath("/_server")).toBe(true); + expect(isServerFunctionPath("/_server/a1b2c3-0-getUser")).toBe(true); + }); + + it("does not match routes that merely share the prefix", () => { + expect(isServerFunctionPath("/_serverless")).toBe(false); + expect(isServerFunctionPath("/about")).toBe(false); + }); +}); + +describe("getFunctionIdFromPath", () => { + it("reads the id from the path", () => { + expect(getFunctionIdFromPath("/_server/a1b2c3-0-getUser")).toBe("a1b2c3-0-getUser"); + }); + + it("decodes the id", () => { + expect(getFunctionIdFromPath("/_server/a1b2c3-0-a%20b")).toBe("a1b2c3-0-a b"); + }); + + it("ignores a deployment base in front of the segment", () => { + expect(getFunctionIdFromPath("/app/_server/a1b2c3-0-getUser")).toBe("a1b2c3-0-getUser"); + }); + + it("returns null when there is no id", () => { + expect(getFunctionIdFromPath("/_server")).toBe(null); + expect(getFunctionIdFromPath("/_server/")).toBe(null); + expect(getFunctionIdFromPath("/about")).toBe(null); + }); + + it("returns null for malformed encoding rather than throwing", () => { + expect(getFunctionIdFromPath("/_server/%E0%A4%A")).toBe(null); + }); +}); diff --git a/packages/start/src/fns/url.ts b/packages/start/src/fns/url.ts new file mode 100644 index 000000000..fe3e801a8 --- /dev/null +++ b/packages/start/src/fns/url.ts @@ -0,0 +1,38 @@ +// Server function requests are sent to `_server/` rather than a bare +// `_server`, so access logs, traces and devtools can tell individual +// functions apart by URL instead of collapsing every call into one entry. +// In development (and in production with `serverFunctions.readableIds`) the id +// ends with the function's source name, which makes the path self-describing. +const SEGMENT = "_server"; +const PREFIX = `/${SEGMENT}/`; + +export const SERVER_FN_BASE = `/${SEGMENT}`; + +/** Builds the request URL for a server function, respecting `BASE_URL`. */ +export function serverFunctionURL(id: string) { + let baseURL = import.meta.env.BASE_URL ?? "/"; + if (!baseURL.endsWith("/")) baseURL += "/"; + return `${baseURL}${SEGMENT}/${encodeURIComponent(id)}`; +} + +/** True for `/_server` and `/_server/`, false for e.g. `/_serverless`. */ +export function isServerFunctionPath(pathname: string) { + return pathname === SERVER_FN_BASE || pathname.startsWith(PREFIX); +} + +/** + * Reads the function id out of a request path. Matches on the last `/_server/` + * so a deployment `base` in front of it does not need to be stripped first. + */ +export function getFunctionIdFromPath(pathname: string): string | null { + const index = pathname.lastIndexOf(PREFIX); + if (index === -1) return null; + const id = pathname.slice(index + PREFIX.length); + if (!id) return null; + try { + return decodeURIComponent(id); + } catch { + // malformed percent-encoding + return null; + } +} diff --git a/packages/start/src/server/handler.ts b/packages/start/src/server/handler.ts index d4fb6e4b7..2c4cb1595 100644 --- a/packages/start/src/server/handler.ts +++ b/packages/start/src/server/handler.ts @@ -17,13 +17,12 @@ import { decorateHandler, decorateMiddleware } from "./fetchEvent.ts"; import { getSsrManifest } from "./manifest/ssr-manifest.ts"; import { matchAPIRoute } from "./routes.ts"; import { handleServerFunction } from "../fns/handler.ts"; +import { isServerFunctionPath } from "../fns/url.ts"; import type { APIEvent, FetchEvent, HandlerOptions, PageEvent, StartHandler } from "./types.ts"; import { getExpectedRedirectStatus } from "./util.ts"; import { toWebReadableStream } from "./web-stream.ts"; import { stripPathBase } from "./strip-path-base.ts"; -const SERVER_FN_BASE = "/_server"; - export function createBaseHandler( createPageEvent: (e: FetchEvent) => Promise, fn: (context: PageEvent) => JSX.Element, @@ -37,7 +36,7 @@ export function createBaseHandler( const url = new URL(event.request.url); const pathname = stripBaseUrl(url.pathname); - if (pathname.startsWith(SERVER_FN_BASE)) { + if (isServerFunctionPath(pathname)) { return await handleServerFunction(e); }