rwsdk 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/register/methodEnforcer.d.ts +8 -0
- package/dist/runtime/register/methodEnforcer.js +41 -0
- package/dist/runtime/register/methodEnforcer.test.d.ts +1 -0
- package/dist/runtime/register/methodEnforcer.test.js +128 -0
- package/dist/runtime/register/worker.js +2 -24
- package/package.json +2 -2
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type GetServerModuleExport = (actionId: string) => Promise<unknown>;
|
|
2
|
+
type DecodeReply = (data: string | FormData, moduleMap: null) => Promise<unknown>;
|
|
3
|
+
export interface RscActionHandlerDeps {
|
|
4
|
+
getServerModuleExport: GetServerModuleExport;
|
|
5
|
+
decodeReply: DecodeReply;
|
|
6
|
+
}
|
|
7
|
+
export declare function rscActionHandler(req: Request, { getServerModuleExport, decodeReply }: RscActionHandlerDeps): Promise<unknown>;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// context(justinvdm, 2026-04-06): Extracted from rscActionHandler() for testability.
|
|
2
|
+
// The real getServerModuleExport and decodeReply require the react-server environment
|
|
3
|
+
// (virtual module lookup, react-server-dom-webpack), so we accept them as injected
|
|
4
|
+
// dependencies. worker.ts passes the real implementations; tests pass fakes.
|
|
5
|
+
export async function rscActionHandler(req, { getServerModuleExport, decodeReply }) {
|
|
6
|
+
const url = new URL(req.url);
|
|
7
|
+
const contentType = req.headers.get("content-type");
|
|
8
|
+
let args = [];
|
|
9
|
+
if (req.method === "GET") {
|
|
10
|
+
const argsParam = url.searchParams.get("args");
|
|
11
|
+
if (argsParam) {
|
|
12
|
+
args = JSON.parse(argsParam);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
const data = contentType?.startsWith("multipart/form-data")
|
|
17
|
+
? await req.formData()
|
|
18
|
+
: await req.text();
|
|
19
|
+
args = (await decodeReply(data, null));
|
|
20
|
+
}
|
|
21
|
+
const actionId = url.searchParams.get("__rsc_action_id");
|
|
22
|
+
if (import.meta.env.VITE_IS_DEV_SERVER && actionId === "__rsc_hot_update") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const action = await getServerModuleExport(actionId);
|
|
26
|
+
if (typeof action !== "function") {
|
|
27
|
+
throw new Error(`Action ${actionId} is not a function`);
|
|
28
|
+
}
|
|
29
|
+
// context(justinvdm, 2026-04-06): Validate the declared HTTP method before
|
|
30
|
+
// invocation. serverAction() attaches .method = "POST" at creation time via
|
|
31
|
+
// createServerFunction(), serverQuery() attaches "GET". Functions without
|
|
32
|
+
// .method default to POST to match serverAction() semantics.
|
|
33
|
+
const actionMethod = action.method ?? "POST";
|
|
34
|
+
if (actionMethod !== req.method) {
|
|
35
|
+
return new Response(`Method ${req.method} is not allowed for this action. Allowed: ${actionMethod}.`, {
|
|
36
|
+
status: 405,
|
|
37
|
+
headers: { Allow: actionMethod },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return action(...args);
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { rscActionHandler } from "./methodEnforcer";
|
|
3
|
+
function createDeps(action) {
|
|
4
|
+
return {
|
|
5
|
+
getServerModuleExport: vi.fn().mockResolvedValue(action),
|
|
6
|
+
decodeReply: vi.fn().mockResolvedValue([]),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
function makeRequest(url, method, body) {
|
|
10
|
+
const init = { method };
|
|
11
|
+
if (body !== undefined) {
|
|
12
|
+
init.body = body;
|
|
13
|
+
init.headers = { "content-type": "application/json" };
|
|
14
|
+
}
|
|
15
|
+
return new Request(url, init);
|
|
16
|
+
}
|
|
17
|
+
const ACTION_URL = "https://app.test/page?__rsc_action_id=%2Factions.tsx%23doThing";
|
|
18
|
+
describe("rscActionHandler method enforcement", () => {
|
|
19
|
+
it("rejects GET for an action with method POST", async () => {
|
|
20
|
+
const action = Object.assign(vi.fn(), { method: "POST" });
|
|
21
|
+
const deps = createDeps(action);
|
|
22
|
+
const req = makeRequest(ACTION_URL, "GET");
|
|
23
|
+
const result = await rscActionHandler(req, deps);
|
|
24
|
+
expect(result).toBeInstanceOf(Response);
|
|
25
|
+
const res = result;
|
|
26
|
+
expect(res.status).toBe(405);
|
|
27
|
+
expect(res.headers.get("Allow")).toBe("POST");
|
|
28
|
+
expect(action).not.toHaveBeenCalled();
|
|
29
|
+
});
|
|
30
|
+
it("rejects POST for an action with method GET", async () => {
|
|
31
|
+
const action = Object.assign(vi.fn(), { method: "GET" });
|
|
32
|
+
const deps = createDeps(action);
|
|
33
|
+
const req = makeRequest(ACTION_URL, "POST", "[]");
|
|
34
|
+
const result = await rscActionHandler(req, deps);
|
|
35
|
+
expect(result).toBeInstanceOf(Response);
|
|
36
|
+
const res = result;
|
|
37
|
+
expect(res.status).toBe(405);
|
|
38
|
+
expect(res.headers.get("Allow")).toBe("GET");
|
|
39
|
+
expect(action).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
it("allows POST for an action with method POST", async () => {
|
|
42
|
+
const action = Object.assign(vi.fn().mockReturnValue("ok"), {
|
|
43
|
+
method: "POST",
|
|
44
|
+
});
|
|
45
|
+
const deps = createDeps(action);
|
|
46
|
+
const req = makeRequest(ACTION_URL, "POST", "[]");
|
|
47
|
+
const result = await rscActionHandler(req, deps);
|
|
48
|
+
expect(result).toBe("ok");
|
|
49
|
+
expect(action).toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
it("allows GET for an action with method GET", async () => {
|
|
52
|
+
const action = Object.assign(vi.fn().mockReturnValue("data"), {
|
|
53
|
+
method: "GET",
|
|
54
|
+
});
|
|
55
|
+
const deps = createDeps(action);
|
|
56
|
+
const req = makeRequest(ACTION_URL + "&args=%5B%5D", "GET");
|
|
57
|
+
const result = await rscActionHandler(req, deps);
|
|
58
|
+
expect(result).toBe("data");
|
|
59
|
+
expect(action).toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
it("defaults to POST for actions without .method property", async () => {
|
|
62
|
+
const action = vi.fn().mockReturnValue("ok");
|
|
63
|
+
const deps = createDeps(action);
|
|
64
|
+
const postReq = makeRequest(ACTION_URL, "POST", "[]");
|
|
65
|
+
const postResult = await rscActionHandler(postReq, deps);
|
|
66
|
+
expect(postResult).toBe("ok");
|
|
67
|
+
expect(action).toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
it("rejects GET for actions without .method property", async () => {
|
|
70
|
+
const action = vi.fn();
|
|
71
|
+
const deps = createDeps(action);
|
|
72
|
+
const getReq = makeRequest(ACTION_URL, "GET");
|
|
73
|
+
const getResult = await rscActionHandler(getReq, deps);
|
|
74
|
+
expect(getResult).toBeInstanceOf(Response);
|
|
75
|
+
expect(getResult.status).toBe(405);
|
|
76
|
+
expect(action).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
it("includes allowed methods in 405 response body", async () => {
|
|
79
|
+
const action = Object.assign(vi.fn(), { method: "POST" });
|
|
80
|
+
const deps = createDeps(action);
|
|
81
|
+
const req = makeRequest(ACTION_URL, "GET");
|
|
82
|
+
const result = (await rscActionHandler(req, deps));
|
|
83
|
+
const body = await result.text();
|
|
84
|
+
expect(body).toContain("Method GET is not allowed");
|
|
85
|
+
expect(body).toContain("Allowed: POST");
|
|
86
|
+
});
|
|
87
|
+
it("rejects when .method is not a valid string", async () => {
|
|
88
|
+
const action = Object.assign(vi.fn(), {
|
|
89
|
+
method: 42,
|
|
90
|
+
});
|
|
91
|
+
const deps = createDeps(action);
|
|
92
|
+
const req = makeRequest(ACTION_URL, "GET");
|
|
93
|
+
const result = await rscActionHandler(req, deps);
|
|
94
|
+
expect(result).toBeInstanceOf(Response);
|
|
95
|
+
expect(result.status).toBe(405);
|
|
96
|
+
expect(action).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
it("throws when action is not a function", async () => {
|
|
99
|
+
const deps = {
|
|
100
|
+
getServerModuleExport: vi.fn().mockResolvedValue("not-a-function"),
|
|
101
|
+
decodeReply: vi.fn(),
|
|
102
|
+
};
|
|
103
|
+
const req = makeRequest(ACTION_URL, "GET");
|
|
104
|
+
await expect(rscActionHandler(req, deps)).rejects.toThrow("is not a function");
|
|
105
|
+
});
|
|
106
|
+
it("parses GET args from query string", async () => {
|
|
107
|
+
const action = Object.assign(vi.fn().mockReturnValue("result"), {
|
|
108
|
+
method: "GET",
|
|
109
|
+
});
|
|
110
|
+
const deps = createDeps(action);
|
|
111
|
+
const req = makeRequest(ACTION_URL + '&args=%5B%22hello%22%2C%2042%5D', "GET");
|
|
112
|
+
await rscActionHandler(req, deps);
|
|
113
|
+
expect(action).toHaveBeenCalledWith("hello", 42);
|
|
114
|
+
});
|
|
115
|
+
it("uses decodeReply for POST body", async () => {
|
|
116
|
+
const action = Object.assign(vi.fn().mockReturnValue("result"), {
|
|
117
|
+
method: "POST",
|
|
118
|
+
});
|
|
119
|
+
const deps = {
|
|
120
|
+
getServerModuleExport: vi.fn().mockResolvedValue(action),
|
|
121
|
+
decodeReply: vi.fn().mockResolvedValue(["decoded-arg"]),
|
|
122
|
+
};
|
|
123
|
+
const req = makeRequest(ACTION_URL, "POST", "serialized-body");
|
|
124
|
+
await rscActionHandler(req, deps);
|
|
125
|
+
expect(deps.decodeReply).toHaveBeenCalledWith("serialized-body", null);
|
|
126
|
+
expect(action).toHaveBeenCalledWith("decoded-arg");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -2,6 +2,7 @@ import { isValidElementType } from "react-is";
|
|
|
2
2
|
import { registerClientReference as baseRegisterClientReference, registerServerReference as baseRegisterServerReference, decodeReply, } from "react-server-dom-webpack/server.edge";
|
|
3
3
|
import { getServerModuleExport } from "../imports/worker.js";
|
|
4
4
|
import { requestInfo } from "../requestInfo/worker.js";
|
|
5
|
+
import { rscActionHandler as rscActionHandlerImpl } from "./methodEnforcer.js";
|
|
5
6
|
export function registerServerReference(action, id, name) {
|
|
6
7
|
if (typeof action !== "function") {
|
|
7
8
|
return action;
|
|
@@ -48,28 +49,5 @@ export async function __smokeTestActionHandler(timestamp) {
|
|
|
48
49
|
return { status: "ok", timestamp };
|
|
49
50
|
}
|
|
50
51
|
export async function rscActionHandler(req) {
|
|
51
|
-
|
|
52
|
-
const contentType = req.headers.get("content-type");
|
|
53
|
-
let args = [];
|
|
54
|
-
if (req.method === "GET") {
|
|
55
|
-
const argsParam = url.searchParams.get("args");
|
|
56
|
-
if (argsParam) {
|
|
57
|
-
args = JSON.parse(argsParam);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
const data = contentType?.startsWith("multipart/form-data")
|
|
62
|
-
? await req.formData()
|
|
63
|
-
: await req.text();
|
|
64
|
-
args = (await decodeReply(data, null));
|
|
65
|
-
}
|
|
66
|
-
const actionId = url.searchParams.get("__rsc_action_id");
|
|
67
|
-
if (import.meta.env.VITE_IS_DEV_SERVER && actionId === "__rsc_hot_update") {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
const action = await getServerModuleExport(actionId);
|
|
71
|
-
if (typeof action !== "function") {
|
|
72
|
-
throw new Error(`Action ${actionId} is not a function`);
|
|
73
|
-
}
|
|
74
|
-
return action(...args);
|
|
52
|
+
return rscActionHandlerImpl(req, { getServerModuleExport, decodeReply });
|
|
75
53
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rwsdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
"jsonc-parser": "~3.3.1",
|
|
179
179
|
"kysely": "~0.28.15",
|
|
180
180
|
"kysely-do": "~0.0.1-rc.1",
|
|
181
|
-
"lodash": "~4.
|
|
181
|
+
"lodash": "~4.18.1",
|
|
182
182
|
"magic-string": "~0.30.21",
|
|
183
183
|
"picocolors": "~1.1.1",
|
|
184
184
|
"proper-lockfile": "~4.1.2",
|