Skip to content

Commit bc6a058

Browse files
authored
feat: add registerServerFunctionWrap for global server function instrumentation (#1137)
## Summary - Adds `registerServerFunctionWrap()` — a global hook that wraps every `serverAction` / `serverQuery` handler for observability (Sentry spans, OpenTelemetry, timing logs, etc.) - Register once in your worker entry point, every server function is instrumented automatically — no per-action boilerplate, no import shims, no lint rules - Throws if called more than once to prevent silent overwrites ## Usage ```ts // worker.tsx import { registerServerFunctionWrap } from "rwsdk/worker"; import * as Sentry from "@sentry/cloudflare"; registerServerFunctionWrap((fn, args, type) => Sentry.startSpan( { name: fn.name, op: `function.rsc_${type}` }, () => fn(...args) ) ); ``` ## Test plan - [x] 9 new tests covering: action wrapping, query wrapping, fn.name/args passed correctly, interruptors run outside wrapper, wrapper skipped on interruptor short-circuit, double registration throws, no-op without registration, return value transformation, array-style `[interruptors..., handler]` - [x] All 490 existing tests still pass Closes #1135 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 2e27088 + aed1aca commit bc6a058

2 files changed

Lines changed: 230 additions & 3 deletions

File tree

sdk/src/runtime/server.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { describe, expect, it, vi, beforeEach } from "vitest";
2+
3+
let mockRequestInfo: { request: Request; ctx: Record<string, any> };
4+
5+
vi.mock("./requestInfo/worker", () => ({
6+
get requestInfo() {
7+
return mockRequestInfo;
8+
},
9+
}));
10+
11+
import {
12+
serverAction,
13+
serverQuery,
14+
registerServerFunctionWrap,
15+
__resetServerFunctionWrap,
16+
} from "./server";
17+
18+
describe("registerServerFunctionWrap", () => {
19+
beforeEach(() => {
20+
__resetServerFunctionWrap();
21+
mockRequestInfo = {
22+
request: new Request("https://test.example/"),
23+
ctx: {},
24+
};
25+
});
26+
27+
it("wraps serverAction handlers", async () => {
28+
const wrapSpy = vi.fn((fn, args, _type) => fn(...args));
29+
registerServerFunctionWrap(wrapSpy);
30+
31+
const action = serverAction(async function createThing(name: string) {
32+
return `created ${name}`;
33+
});
34+
35+
const result = await action("foo");
36+
37+
expect(wrapSpy).toHaveBeenCalledOnce();
38+
expect(wrapSpy.mock.calls[0][2]).toBe("action");
39+
expect(result).toBe("created foo");
40+
});
41+
42+
it("wraps serverQuery handlers", async () => {
43+
const wrapSpy = vi.fn((fn, args, _type) => fn(...args));
44+
registerServerFunctionWrap(wrapSpy);
45+
46+
const query = serverQuery(async function getThings() {
47+
return [1, 2, 3];
48+
});
49+
50+
const result = await query();
51+
52+
expect(wrapSpy).toHaveBeenCalledOnce();
53+
expect(wrapSpy.mock.calls[0][2]).toBe("query");
54+
expect(result).toEqual([1, 2, 3]);
55+
});
56+
57+
it("passes the main function and args to the wrapper", async () => {
58+
const wrapSpy = vi.fn((fn, args, _type) => fn(...args));
59+
registerServerFunctionWrap(wrapSpy);
60+
61+
const action = serverAction(
62+
async function multiply(a: number, b: number) {
63+
return a * b;
64+
},
65+
);
66+
67+
await action(3, 7);
68+
69+
const [fn, args] = wrapSpy.mock.calls[0];
70+
expect(fn.name).toBe("multiply");
71+
expect(args).toEqual([3, 7]);
72+
});
73+
74+
it("interruptors run outside the wrapper", async () => {
75+
const order: string[] = [];
76+
77+
registerServerFunctionWrap((fn, args, _type) => {
78+
order.push("wrap:start");
79+
const result = fn(...args);
80+
order.push("wrap:end");
81+
return result;
82+
});
83+
84+
const interruptor = async () => {
85+
order.push("interruptor");
86+
};
87+
88+
const action = serverAction([
89+
interruptor,
90+
async function doWork() {
91+
order.push("handler");
92+
return "done";
93+
},
94+
]);
95+
96+
await action();
97+
98+
expect(order).toEqual([
99+
"interruptor",
100+
"wrap:start",
101+
"handler",
102+
"wrap:end",
103+
]);
104+
});
105+
106+
it("wrapper is not called when an interruptor short-circuits", async () => {
107+
const wrapSpy = vi.fn((fn, args, _type) => fn(...args));
108+
registerServerFunctionWrap(wrapSpy);
109+
110+
const action = serverAction([
111+
async () => new Response("blocked", { status: 403 }),
112+
async function neverCalled() {
113+
return "nope";
114+
},
115+
]);
116+
117+
const result = await action();
118+
119+
expect(wrapSpy).not.toHaveBeenCalled();
120+
expect(result).toBeInstanceOf(Response);
121+
});
122+
123+
it("throws if called more than once", () => {
124+
registerServerFunctionWrap(async (fn, args) => fn(...args));
125+
126+
expect(() => {
127+
registerServerFunctionWrap(async (fn, args) => fn(...args));
128+
}).toThrow("registerServerFunctionWrap() has already been called");
129+
});
130+
131+
it("without registration, handlers work normally", async () => {
132+
const action = serverAction(async function echo(msg: string) {
133+
return msg;
134+
});
135+
136+
const result = await action("hello");
137+
138+
expect(result).toBe("hello");
139+
});
140+
141+
it("wrapper can transform the return value", async () => {
142+
registerServerFunctionWrap(async (fn, args, _type) => {
143+
const result = await fn(...args);
144+
return { wrapped: true, result };
145+
});
146+
147+
const action = serverAction(async function getValue() {
148+
return 42;
149+
});
150+
151+
const result = await action();
152+
153+
expect(result).toEqual({ wrapped: true, result: 42 });
154+
});
155+
156+
it("works with array-style serverAction (interruptors + handler)", async () => {
157+
const wrapSpy = vi.fn((fn, args, _type) => fn(...args));
158+
registerServerFunctionWrap(wrapSpy);
159+
160+
const action = serverAction([
161+
async ({ ctx }: any) => {
162+
ctx.authed = true;
163+
},
164+
async function save(data: string) {
165+
return `saved ${data}`;
166+
},
167+
]);
168+
169+
const result = await action("test");
170+
171+
expect(result).toBe("saved test");
172+
expect(wrapSpy).toHaveBeenCalledOnce();
173+
const [fn] = wrapSpy.mock.calls[0];
174+
expect(fn.name).toBe("save");
175+
});
176+
});

sdk/src/runtime/server.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,57 @@ type WrappedServerFunction<TArgs extends any[] = any[], TResult = any> = {
1818
method?: "GET" | "POST";
1919
};
2020

21+
export type ServerFunctionWrap = (
22+
fn: Function,
23+
args: any[],
24+
type: "action" | "query",
25+
) => Promise<any>;
26+
27+
let globalWrap: ServerFunctionWrap | undefined;
28+
29+
/**
30+
* Register a wrapper that runs around every server action and query handler.
31+
*
32+
* Call this once in your worker entry point. The wrapper receives the main
33+
* handler function, its arguments, and the type ("action" or "query").
34+
* Interruptors run *outside* the wrapper.
35+
*
36+
* Throws if called more than once.
37+
*
38+
* @example
39+
* ```ts
40+
* import { registerServerFunctionWrap } from "rwsdk/worker";
41+
* import * as Sentry from "@sentry/cloudflare";
42+
*
43+
* registerServerFunctionWrap((fn, args, type) =>
44+
* Sentry.startSpan(
45+
* { name: fn.name, op: `function.rsc_${type}` },
46+
* () => fn(...args)
47+
* )
48+
* );
49+
* ```
50+
*/
51+
export function registerServerFunctionWrap(wrap: ServerFunctionWrap) {
52+
if (globalWrap) {
53+
throw new Error(
54+
"registerServerFunctionWrap() has already been called. " +
55+
"Only one wrapper can be registered.",
56+
);
57+
}
58+
globalWrap = wrap;
59+
}
60+
61+
/**
62+
* @internal Reset the global wrap — only for tests.
63+
*/
64+
export function __resetServerFunctionWrap() {
65+
globalWrap = undefined;
66+
}
67+
2168
function createServerFunction<TArgs extends any[] = any[], TResult = any>(
2269
fns: Interruptor<TArgs>[],
2370
mainFn: ServerFunction<TArgs, TResult>,
24-
options?: ServerFunctionOptions,
71+
options?: ServerFunctionOptions & { __type?: "action" | "query" },
2572
): WrappedServerFunction<TArgs, TResult> {
2673
const wrapped: WrappedServerFunction<TArgs, TResult> = async (
2774
...args: TArgs
@@ -38,6 +85,10 @@ function createServerFunction<TArgs extends any[] = any[], TResult = any>(
3885
}
3986
}
4087

88+
if (globalWrap) {
89+
return globalWrap(mainFn, args, options?.__type ?? "action") as Promise<TResult>;
90+
}
91+
4192
return mainFn(...args);
4293
};
4394

@@ -81,7 +132,7 @@ export function serverQuery<TArgs extends any[], TResult>(
81132
}
82133

83134
const method = options?.method ?? "GET"; // Default to GET for query
84-
const wrapped = createServerFunction(fns, mainFn, { ...options, method });
135+
const wrapped = createServerFunction(fns, mainFn, { ...options, method, __type: "query" });
85136
wrapped.method = method;
86137
return wrapped;
87138
}
@@ -121,7 +172,7 @@ export function serverAction<TArgs extends any[], TResult>(
121172
}
122173

123174
const method = options?.method ?? "POST"; // Default to POST for action
124-
const wrapped = createServerFunction(fns, mainFn, { ...options, method });
175+
const wrapped = createServerFunction(fns, mainFn, { ...options, method, __type: "action" });
125176
wrapped.method = method;
126177
return wrapped;
127178
}

0 commit comments

Comments
 (0)