Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/khaki-forks-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@solidjs/start": minor
---

Include the server function id in its request URL

Server function calls now go to `_server/<id>` 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=<id>` URL are still handled, so bookmarked
or hand-written URLs keep working.
6 changes: 4 additions & 2 deletions packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerFunctionsOptions, "filter">;
serverFunctions?: Pick<ServerFunctionsOptions, "filter" | "readableIds">;
}

const absolute = (path: string, root: string) =>
Expand Down Expand Up @@ -160,6 +161,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
client: "@solidjs/start/fns/client",
},
filter: options?.serverFunctions?.filter,
readableIds: options?.serverFunctions?.readableIds,
}),
{
name: "solid-start:config",
Expand Down
8 changes: 8 additions & 0 deletions packages/start/src/directives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` 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}";
Expand Down Expand Up @@ -240,6 +247,7 @@ export function serverFunctionsPlugin(options: ServerFunctionsOptions): Plugin[]
...(mode === "server" ? serverOptions : clientOptions),
mode,
env,
readableIds: options.readableIds,
});

if (result.valid) {
Expand Down
7 changes: 6 additions & 1 deletion packages/start/src/directives/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface StateContext {
count: number;
imports: Map<string, t.Identifier>;
valid: boolean;
/** Keep the function name in the generated id in production builds too. */
readableIds?: boolean;

definitions: {
register: ImportDefinition;
Expand Down Expand Up @@ -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/<id>` 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;
Expand Down
13 changes: 6 additions & 7 deletions packages/start/src/fns/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -102,31 +103,29 @@ 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";
return fetchServerFunction(
encodeArgs
? url +
(args.length
? `&args=${encodeURIComponent(await serializeToJSONString(args))}`
? `?args=${encodeURIComponent(await serializeToJSONString(args))}`
: "")
: `${baseURL}_server`,
: url,
id,
options,
encodeArgs ? [] : args,
Expand Down
4 changes: 3 additions & 1 deletion packages/start/src/fns/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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/<id>`
functionId = getFunctionIdFromPath(url.pathname) ?? url.searchParams.get("id");

if (!functionId) {
return process.env.NODE_ENV === "development"
Expand Down
6 changes: 3 additions & 3 deletions packages/start/src/fns/server.ts
Original file line number Diff line number Diff line change
@@ -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<T extends any[], R> {
id: string;
Expand All @@ -19,13 +20,12 @@ export function createServerReference<T extends any[], R>(
export function cloneServerReference<T extends any[], R>({ id, fn }: Registration<T, R>) {
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];
Expand Down
48 changes: 48 additions & 0 deletions packages/start/src/fns/url.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
38 changes: 38 additions & 0 deletions packages/start/src/fns/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Server function requests are sent to `<base>_server/<id>` rather than a bare
// `<base>_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/<id>`, 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;
}
}
5 changes: 2 additions & 3 deletions packages/start/src/server/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PageEvent>,
fn: (context: PageEvent) => JSX.Element,
Expand All @@ -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);
}

Expand Down
Loading