rwsdk 1.0.0-beta.14 → 1.0.0-beta.16

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.
@@ -7,5 +7,6 @@ export declare const VENDOR_CLIENT_BARREL_PATH: string;
7
7
  export declare const VENDOR_SERVER_BARREL_PATH: string;
8
8
  export declare const VENDOR_CLIENT_BARREL_EXPORT_PATH = "rwsdk/__vendor_client_barrel";
9
9
  export declare const VENDOR_SERVER_BARREL_EXPORT_PATH = "rwsdk/__vendor_server_barrel";
10
+ export declare const RW_STATE_EXPORT_PATH = "rwsdk/__state";
10
11
  export declare const INTERMEDIATE_SSR_BRIDGE_PATH: string;
11
12
  export declare const CLIENT_MANIFEST_RELATIVE_PATH: string;
@@ -9,5 +9,6 @@ export const VENDOR_CLIENT_BARREL_PATH = path.resolve(INTERMEDIATES_OUTPUT_DIR,
9
9
  export const VENDOR_SERVER_BARREL_PATH = path.resolve(INTERMEDIATES_OUTPUT_DIR, "rwsdk-vendor-server-barrel.js");
10
10
  export const VENDOR_CLIENT_BARREL_EXPORT_PATH = "rwsdk/__vendor_client_barrel";
11
11
  export const VENDOR_SERVER_BARREL_EXPORT_PATH = "rwsdk/__vendor_server_barrel";
12
+ export const RW_STATE_EXPORT_PATH = "rwsdk/__state";
12
13
  export const INTERMEDIATE_SSR_BRIDGE_PATH = resolve(INTERMEDIATES_OUTPUT_DIR, "ssr", "ssr_bridge.js");
13
14
  export const CLIENT_MANIFEST_RELATIVE_PATH = resolve("dist", "client", ".vite", "manifest.json");
@@ -1,4 +1,5 @@
1
- export declare const IS_DEBUG_MODE: string | boolean;
1
+ export declare const IS_CI: boolean;
2
+ export declare const IS_DEBUG_MODE: boolean;
2
3
  export declare const SETUP_PLAYGROUND_ENV_TIMEOUT: number;
3
4
  export declare const DEPLOYMENT_TIMEOUT: number;
4
5
  export declare const DEPLOYMENT_MIN_TRIES: number;
@@ -1,6 +1,13 @@
1
+ export const IS_CI = !!((process.env.CI && !process.env.NOT_CI) ||
2
+ process.env.GITHUB_ACTIONS ||
3
+ process.env.GITLAB_CI ||
4
+ process.env.CIRCLECI ||
5
+ process.env.TRAVIS ||
6
+ process.env.JENKINS_URL ||
7
+ process.env.NETLIFY);
1
8
  export const IS_DEBUG_MODE = process.env.RWSDK_E2E_DEBUG
2
- ? process.env.RWSDK_E2E_DEBUG
3
- : !process.env.CI;
9
+ ? process.env.RWSDK_E2E_DEBUG === "true"
10
+ : !IS_CI;
4
11
  export const SETUP_PLAYGROUND_ENV_TIMEOUT = process.env
5
12
  .RWSDK_SETUP_PLAYGROUND_ENV_TIMEOUT
6
13
  ? parseInt(process.env.RWSDK_SETUP_PLAYGROUND_ENV_TIMEOUT, 10)
@@ -109,7 +109,6 @@ export async function runDevServer(packageManager = "pnpm", cwd) {
109
109
  // Listen for all output to get the URL
110
110
  const handleOutput = (data, source) => {
111
111
  const output = data.toString();
112
- console.log(output);
113
112
  allOutput += output; // Accumulate all output
114
113
  log("Received output from %s: %s", source, output.replace(/\n/g, "\\n"));
115
114
  if (!url) {
@@ -1,3 +1,4 @@
1
+ import { createHash } from "crypto";
1
2
  import debug from "debug";
2
3
  import { copy, pathExists } from "fs-extra";
3
4
  import ignore from "ignore";
@@ -8,12 +9,25 @@ import { basename, join, relative, resolve } from "path";
8
9
  import tmp from "tmp-promise";
9
10
  import { $ } from "../../lib/$.mjs";
10
11
  import { ROOT_DIR } from "../constants.mjs";
11
- import { INSTALL_DEPENDENCIES_RETRIES } from "./constants.mjs";
12
+ import { INSTALL_DEPENDENCIES_RETRIES, IS_CI } from "./constants.mjs";
12
13
  import { retry } from "./retry.mjs";
13
14
  const log = debug("rwsdk:e2e:environment");
15
+ const IS_CACHE_ENABLED = process.env.RWSDK_E2E_CACHE
16
+ ? process.env.RWSDK_E2E_CACHE === "1"
17
+ : !IS_CI;
18
+ if (IS_CACHE_ENABLED) {
19
+ log("E2E test caching is enabled.");
20
+ }
14
21
  const getTempDir = async () => {
15
22
  return tmp.dir({ unsafeCleanup: true });
16
23
  };
24
+ function slugify(str) {
25
+ return str
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9-]/g, "-")
28
+ .replace(/--+/g, "-")
29
+ .replace(/^-|-$/g, "");
30
+ }
17
31
  const createSdkTarball = async () => {
18
32
  const existingTarballPath = process.env.RWSKD_SMOKE_TEST_TARBALL_PATH;
19
33
  if (existingTarballPath) {
@@ -28,15 +42,27 @@ const createSdkTarball = async () => {
28
42
  }, // No-op cleanup
29
43
  };
30
44
  }
31
- const packResult = await $({ cwd: ROOT_DIR, stdio: "pipe" }) `npm pack`;
32
- const tarballName = packResult.stdout?.trim();
33
- const tarballPath = path.join(ROOT_DIR, tarballName);
34
- log(`📦 Created tarball: ${tarballPath}`);
45
+ // Create a temporary directory to receive the tarball, ensuring a stable path.
46
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "rwsdk-tarball-"));
47
+ await $({
48
+ cwd: ROOT_DIR,
49
+ stdio: "pipe",
50
+ }) `npm pack --pack-destination=${tempDir}`;
51
+ // We need to determine the tarball's name, as it's version-dependent.
52
+ // Running `npm pack --dry-run` gives us the filename without creating a file.
53
+ const packDryRun = await $({
54
+ cwd: ROOT_DIR,
55
+ stdio: "pipe",
56
+ }) `npm pack --dry-run`;
57
+ const tarballName = packDryRun.stdout?.trim();
58
+ const tarballPath = path.join(tempDir, tarballName);
59
+ if (!fs.existsSync(tarballPath)) {
60
+ throw new Error(`Tarball was not created in the expected location: ${tarballPath}`);
61
+ }
62
+ log(`📦 Created tarball in stable temp location: ${tarballPath}`);
35
63
  const cleanupTarball = async () => {
36
- if (fs.existsSync(tarballPath)) {
37
- log(`🧹 Cleaning up tarball: ${tarballPath}`);
38
- await fs.promises.rm(tarballPath, { force: true });
39
- }
64
+ log(`🧹 Cleaning up tarball directory: ${tempDir}`);
65
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
40
66
  };
41
67
  return { tarballPath, cleanupTarball };
42
68
  };
@@ -59,7 +85,7 @@ export async function copyProjectToTempDir(projectDir, resourceUniqueKey, packag
59
85
  const sourceDir = monorepoRoot || projectDir;
60
86
  // Create unique project directory name
61
87
  const originalDirName = basename(sourceDir);
62
- const workerName = `${originalDirName}-test-${resourceUniqueKey}`;
88
+ const workerName = `${slugify(originalDirName)}-test-${resourceUniqueKey}`;
63
89
  const tempCopyRoot = resolve(tempDir.path, workerName);
64
90
  // If it's a monorepo, the targetDir for commands is a subdirectory
65
91
  const targetDir = monorepoRoot
@@ -115,7 +141,9 @@ export async function copyProjectToTempDir(projectDir, resourceUniqueKey, packag
115
141
  const workspaces = rwsdkWs.workspaces;
116
142
  if (packageManager === "pnpm") {
117
143
  const pnpmWsPath = join(tempCopyRoot, "pnpm-workspace.yaml");
118
- const pnpmWsConfig = `packages:\n${workspaces.map((w) => ` - '${w}'`).join("\n")}\n`;
144
+ const pnpmWsConfig = `packages:\n${workspaces
145
+ .map((w) => ` - '${w}'`)
146
+ .join("\n")}\n`;
119
147
  await fs.promises.writeFile(pnpmWsPath, pnpmWsConfig);
120
148
  log("Created pnpm-workspace.yaml");
121
149
  }
@@ -157,7 +185,7 @@ export async function copyProjectToTempDir(projectDir, resourceUniqueKey, packag
157
185
  await setTarballDependency(targetDir, tarballFilename);
158
186
  // Install dependencies in the target directory
159
187
  const installDir = monorepoRoot ? tempCopyRoot : targetDir;
160
- await retry(() => installDependencies(installDir, packageManager), {
188
+ await retry(() => installDependencies(installDir, packageManager, projectDir, monorepoRoot), {
161
189
  retries: INSTALL_DEPENDENCIES_RETRIES,
162
190
  delay: 1000,
163
191
  });
@@ -168,7 +196,42 @@ export async function copyProjectToTempDir(projectDir, resourceUniqueKey, packag
168
196
  await cleanupTarball();
169
197
  }
170
198
  }
171
- async function installDependencies(targetDir, packageManager = "pnpm") {
199
+ async function installDependencies(targetDir, packageManager = "pnpm", projectDir, monorepoRoot) {
200
+ if (IS_CACHE_ENABLED) {
201
+ // Generate a checksum of the SDK's dist directory to factor into the cache key
202
+ const { stdout: sdkDistChecksum } = await $("find . -type f | sort | md5sum", {
203
+ shell: true,
204
+ cwd: path.join(ROOT_DIR, "dist"),
205
+ });
206
+ const projectIdentifier = monorepoRoot
207
+ ? `${monorepoRoot}-${projectDir}`
208
+ : projectDir;
209
+ const projectHash = createHash("md5")
210
+ .update(`${projectIdentifier}-${sdkDistChecksum}`)
211
+ .digest("hex")
212
+ .substring(0, 8);
213
+ const cacheDirName = monorepoRoot
214
+ ? basename(monorepoRoot)
215
+ : basename(projectDir);
216
+ const cacheRoot = path.join(os.tmpdir(), "rwsdk-e2e-cache", `${cacheDirName}-${projectHash}`);
217
+ const nodeModulesCachePath = path.join(cacheRoot, "node_modules");
218
+ if (await pathExists(nodeModulesCachePath)) {
219
+ console.log(`✅ CACHE HIT for source "${projectIdentifier}": Found cached node_modules. Hard-linking from ${nodeModulesCachePath}`);
220
+ try {
221
+ // Use cp -al for a fast, hardlink-based copy
222
+ await $("cp", ["-al", nodeModulesCachePath, join(targetDir, "node_modules")], { stdio: "pipe" });
223
+ console.log(`✅ Hardlink copy created successfully.`);
224
+ }
225
+ catch (e) {
226
+ console.warn(`⚠️ Hardlink copy failed, falling back to full copy. Error: ${e.message}`);
227
+ // Fallback to a regular copy if hardlinking fails (e.g., cross-device)
228
+ await copy(nodeModulesCachePath, join(targetDir, "node_modules"));
229
+ console.log(`✅ Full copy created successfully.`);
230
+ }
231
+ return;
232
+ }
233
+ console.log(`ℹ️ CACHE MISS for source "${projectIdentifier}": No cached node_modules found at ${nodeModulesCachePath}. Proceeding with installation.`);
234
+ }
172
235
  console.log(`📦 Installing project dependencies in ${targetDir} using ${packageManager}...`);
173
236
  try {
174
237
  // Clean up any pre-existing node_modules and lockfiles
@@ -220,6 +283,33 @@ async function installDependencies(targetDir, packageManager = "pnpm") {
220
283
  },
221
284
  });
222
285
  console.log("✅ Dependencies installed successfully");
286
+ // After successful install, populate the cache if enabled
287
+ if (IS_CACHE_ENABLED) {
288
+ // Re-calculate cache path to be safe
289
+ const { stdout: sdkDistChecksum } = await $("find . -type f | sort | md5sum", {
290
+ shell: true,
291
+ cwd: path.join(ROOT_DIR, "dist"),
292
+ });
293
+ const projectIdentifier = monorepoRoot
294
+ ? `${monorepoRoot}-${projectDir}`
295
+ : projectDir;
296
+ const projectHash = createHash("md5")
297
+ .update(`${projectIdentifier}-${sdkDistChecksum}`)
298
+ .digest("hex")
299
+ .substring(0, 8);
300
+ const cacheDirName = monorepoRoot
301
+ ? basename(monorepoRoot)
302
+ : basename(projectDir);
303
+ const cacheRoot = path.join(os.tmpdir(), "rwsdk-e2e-cache", `${cacheDirName}-${projectHash}`);
304
+ const nodeModulesCachePath = path.join(cacheRoot, "node_modules");
305
+ console.log(`Caching node_modules to ${nodeModulesCachePath} for future runs...`);
306
+ // Ensure parent directory exists
307
+ await fs.promises.mkdir(path.dirname(nodeModulesCachePath), {
308
+ recursive: true,
309
+ });
310
+ await copy(join(targetDir, "node_modules"), nodeModulesCachePath);
311
+ console.log(`✅ node_modules cached successfully.`);
312
+ }
223
313
  // Log installation details at debug level
224
314
  if (result.stdout) {
225
315
  log(`${packageManager} install output: %s`, result.stdout);
@@ -4,5 +4,5 @@ export interface PollOptions {
4
4
  minTries: number;
5
5
  onRetry?: (error: unknown, tries: number) => void;
6
6
  }
7
- export declare function poll(fn: () => Promise<boolean>, options?: Partial<PollOptions>): Promise<void>;
7
+ export declare function poll(fn: () => boolean | Promise<boolean>, options?: Partial<PollOptions>): Promise<void>;
8
8
  export declare function pollValue<T>(fn: () => Promise<T>, options?: Partial<PollOptions>): Promise<T>;
@@ -37,6 +37,11 @@ export interface SetupPlaygroundEnvironmentOptions {
37
37
  * @default true
38
38
  */
39
39
  deploy?: boolean;
40
+ /**
41
+ * Whether to automatically start the dev server.
42
+ * @default true
43
+ */
44
+ autoStartDevServer?: boolean;
40
45
  }
41
46
  /**
42
47
  * A Vitest hook that sets up a playground environment for a test file.
@@ -77,8 +82,6 @@ export declare function createDeployment(): {
77
82
  */
78
83
  export declare function runTestWithRetries(name: string, attemptFn: () => Promise<void>): Promise<void>;
79
84
  type SDKRunner = (name: string, testLogic: (context: {
80
- createDevServer: () => Promise<DevServerInstance>;
81
- createDeployment: () => Promise<DeploymentInstance>;
82
85
  browser: Browser;
83
86
  page: Page;
84
87
  projectDir: string;
@@ -85,7 +85,9 @@ function getPlaygroundDirFromImportMeta(importMetaUrl) {
85
85
  * This ensures that tests run in a clean, isolated environment.
86
86
  */
87
87
  export function setupPlaygroundEnvironment(options) {
88
- const { sourceProjectDir, monorepoRoot, dev = true, deploy = true, } = typeof options === "string" ? { sourceProjectDir: options } : options;
88
+ const { sourceProjectDir, monorepoRoot, dev = true, deploy = true, autoStartDevServer = true, } = typeof options === "string"
89
+ ? { sourceProjectDir: options, autoStartDevServer: true }
90
+ : options;
89
91
  ensureHooksRegistered();
90
92
  beforeAll(async () => {
91
93
  let projectDir;
@@ -111,14 +113,16 @@ export function setupPlaygroundEnvironment(options) {
111
113
  projectDir: devEnv.targetDir,
112
114
  cleanup: devEnv.cleanup,
113
115
  };
114
- const devControl = createDevServer();
115
- globalDevInstancePromise = devControl.start().then((instance) => {
116
- globalDevInstance = instance;
117
- return instance;
118
- });
119
- // Prevent unhandled promise rejections. The error will be handled inside
120
- // the test's beforeEach hook where this promise is awaited.
121
- globalDevInstancePromise.catch(() => { });
116
+ if (autoStartDevServer) {
117
+ const devControl = createDevServer();
118
+ globalDevInstancePromise = devControl.start().then((instance) => {
119
+ globalDevInstance = instance;
120
+ return instance;
121
+ });
122
+ // Prevent unhandled promise rejections. The error will be handled inside
123
+ // the test's beforeEach hook where this promise is awaited.
124
+ globalDevInstancePromise.catch(() => { });
125
+ }
122
126
  }
123
127
  else {
124
128
  globalDevPlaygroundEnv = null;
@@ -6,10 +6,23 @@ type RouteFunction<T extends RequestInfo = RequestInfo> = (requestInfo: T) => Ma
6
6
  type MaybePromise<T> = T | Promise<T>;
7
7
  type RouteComponent<T extends RequestInfo = RequestInfo> = (requestInfo: T) => MaybePromise<React.JSX.Element | Response | void>;
8
8
  type RouteHandler<T extends RequestInfo = RequestInfo> = RouteFunction<T> | RouteComponent<T> | [...RouteMiddleware<T>[], RouteFunction<T> | RouteComponent<T>];
9
+ declare const METHOD_VERBS: readonly ["delete", "get", "head", "patch", "post", "put"];
10
+ export type MethodVerb = (typeof METHOD_VERBS)[number];
11
+ export type MethodHandlers<T extends RequestInfo = RequestInfo> = {
12
+ [K in MethodVerb]?: RouteHandler<T>;
13
+ } & {
14
+ config?: {
15
+ disable405?: true;
16
+ disableOptions?: true;
17
+ };
18
+ custom?: {
19
+ [method: string]: RouteHandler<T>;
20
+ };
21
+ };
9
22
  export type Route<T extends RequestInfo = RequestInfo> = RouteMiddleware<T> | RouteDefinition<T> | Array<Route<T>>;
10
23
  export type RouteDefinition<T extends RequestInfo = RequestInfo> = {
11
24
  path: string;
12
- handler: RouteHandler<T>;
25
+ handler: RouteHandler<T> | MethodHandlers<T>;
13
26
  layouts?: React.FC<LayoutProps<T>>[];
14
27
  };
15
28
  export declare function matchPath<T extends RequestInfo = RequestInfo>(routePath: string, requestPath: string): T["params"] | null;
@@ -24,7 +37,7 @@ export declare function defineRoutes<T extends RequestInfo = RequestInfo>(routes
24
37
  rscActionHandler: (request: Request) => Promise<unknown>;
25
38
  }) => Response | Promise<Response>;
26
39
  };
27
- export declare function route<T extends RequestInfo = RequestInfo>(path: string, handler: RouteHandler<T>): RouteDefinition<T>;
40
+ export declare function route<T extends RequestInfo = RequestInfo>(path: string, handler: RouteHandler<T> | MethodHandlers<T>): RouteDefinition<T>;
28
41
  export declare function index<T extends RequestInfo = RequestInfo>(handler: RouteHandler<T>): RouteDefinition<T>;
29
42
  export declare function prefix<T extends RequestInfo = RequestInfo>(prefixPath: string, routes: Route<T>[]): Route<T>[];
30
43
  export declare const wrapHandlerToThrowResponses: <T extends RequestInfo = RequestInfo>(handler: RouteFunction<T> | RouteComponent<T>) => RouteHandler<T>;
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { isValidElementType } from "react-is";
3
+ const METHOD_VERBS = ["delete", "get", "head", "patch", "post", "put"];
3
4
  export function matchPath(routePath, requestPath) {
4
5
  // Check for invalid pattern: multiple colons in a segment (e.g., /:param1:param2/)
5
6
  if (routePath.includes(":")) {
@@ -60,6 +61,38 @@ function flattenRoutes(routes) {
60
61
  return [...acc, route];
61
62
  }, []);
62
63
  }
64
+ function isMethodHandlers(handler) {
65
+ return (typeof handler === "object" && handler !== null && !Array.isArray(handler));
66
+ }
67
+ function handleOptionsRequest(methodHandlers) {
68
+ const methods = new Set([
69
+ ...(methodHandlers.config?.disableOptions ? [] : ["OPTIONS"]),
70
+ ...METHOD_VERBS.filter((verb) => methodHandlers[verb]).map((verb) => verb.toUpperCase()),
71
+ ...Object.keys(methodHandlers.custom ?? {}).map((method) => method.toUpperCase()),
72
+ ]);
73
+ return new Response(null, {
74
+ status: 204,
75
+ headers: {
76
+ Allow: Array.from(methods).sort().join(", "),
77
+ },
78
+ });
79
+ }
80
+ function handleMethodNotAllowed(methodHandlers) {
81
+ const optionsResponse = handleOptionsRequest(methodHandlers);
82
+ return new Response("Method Not Allowed", {
83
+ status: 405,
84
+ headers: optionsResponse.headers,
85
+ });
86
+ }
87
+ function getHandlerForMethod(methodHandlers, method) {
88
+ const lowerMethod = method.toLowerCase();
89
+ // Check standard method verbs
90
+ if (METHOD_VERBS.includes(lowerMethod)) {
91
+ return methodHandlers[lowerMethod];
92
+ }
93
+ // Check custom methods (already normalized to lowercase)
94
+ return methodHandlers.custom?.[lowerMethod];
95
+ }
63
96
  export function defineRoutes(routes) {
64
97
  const flattenedRoutes = flattenRoutes(routes);
65
98
  return {
@@ -125,9 +158,32 @@ export function defineRoutes(routes) {
125
158
  if (!params) {
126
159
  continue; // Not a match, keep going.
127
160
  }
161
+ // Resolve handler if method-based routing
162
+ let handler;
163
+ if (isMethodHandlers(route.handler)) {
164
+ const requestMethod = request.method;
165
+ // Handle OPTIONS request
166
+ if (requestMethod === "OPTIONS" &&
167
+ !route.handler.config?.disableOptions) {
168
+ return handleOptionsRequest(route.handler);
169
+ }
170
+ // Try to find handler for the request method
171
+ handler = getHandlerForMethod(route.handler, requestMethod);
172
+ if (!handler) {
173
+ // Method not supported for this route
174
+ if (!route.handler.config?.disable405) {
175
+ return handleMethodNotAllowed(route.handler);
176
+ }
177
+ // If 405 is disabled, continue to next route
178
+ continue;
179
+ }
180
+ }
181
+ else {
182
+ handler = route.handler;
183
+ }
128
184
  // Found a match: run route-specific middlewares, then the final component, then stop.
129
185
  return await runWithRequestInfoOverrides({ params }, async () => {
130
- const { routeMiddlewares, componentHandler } = parseHandlers(route.handler);
186
+ const { routeMiddlewares, componentHandler } = parseHandlers(handler);
131
187
  // Route-specific middlewares
132
188
  for (const mw of routeMiddlewares) {
133
189
  const result = await mw(getRequestInfo());
@@ -169,6 +225,16 @@ export function route(path, handler) {
169
225
  if (!path.endsWith("/")) {
170
226
  path = path + "/";
171
227
  }
228
+ // Normalize custom method keys to lowercase
229
+ if (isMethodHandlers(handler) && handler.custom) {
230
+ handler = {
231
+ ...handler,
232
+ custom: Object.fromEntries(Object.entries(handler.custom).map(([method, methodHandler]) => [
233
+ method.toLowerCase(),
234
+ methodHandler,
235
+ ])),
236
+ };
237
+ }
172
238
  return {
173
239
  path,
174
240
  handler,
@@ -595,6 +595,245 @@ describe("defineRoutes - Request Handling Behavior", () => {
595
595
  expect(extractedParams).toEqual({ id: "123" });
596
596
  });
597
597
  });
598
+ describe("HTTP Method Routing", () => {
599
+ it("should route GET request to get handler", async () => {
600
+ const router = defineRoutes([
601
+ route("/test/", {
602
+ get: () => new Response("GET Response"),
603
+ post: () => new Response("POST Response"),
604
+ }),
605
+ ]);
606
+ const deps = createMockDependencies();
607
+ deps.mockRequestInfo.request = new Request("http://localhost:3000/test/", {
608
+ method: "GET",
609
+ });
610
+ const request = new Request("http://localhost:3000/test/", {
611
+ method: "GET",
612
+ });
613
+ const response = await router.handle({
614
+ request,
615
+ renderPage: deps.mockRenderPage,
616
+ getRequestInfo: deps.getRequestInfo,
617
+ onError: deps.onError,
618
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
619
+ rscActionHandler: deps.mockRscActionHandler,
620
+ });
621
+ expect(await response.text()).toBe("GET Response");
622
+ });
623
+ it("should route POST request to post handler", async () => {
624
+ const router = defineRoutes([
625
+ route("/test/", {
626
+ get: () => new Response("GET Response"),
627
+ post: () => new Response("POST Response"),
628
+ }),
629
+ ]);
630
+ const deps = createMockDependencies();
631
+ deps.mockRequestInfo.request = new Request("http://localhost:3000/test/", {
632
+ method: "POST",
633
+ });
634
+ const request = new Request("http://localhost:3000/test/", {
635
+ method: "POST",
636
+ });
637
+ const response = await router.handle({
638
+ request,
639
+ renderPage: deps.mockRenderPage,
640
+ getRequestInfo: deps.getRequestInfo,
641
+ onError: deps.onError,
642
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
643
+ rscActionHandler: deps.mockRscActionHandler,
644
+ });
645
+ expect(await response.text()).toBe("POST Response");
646
+ });
647
+ it("should return 405 for unsupported method with Allow header", async () => {
648
+ const router = defineRoutes([
649
+ route("/test/", {
650
+ get: () => new Response("GET Response"),
651
+ post: () => new Response("POST Response"),
652
+ }),
653
+ ]);
654
+ const deps = createMockDependencies();
655
+ deps.mockRequestInfo.request = new Request("http://localhost:3000/test/", {
656
+ method: "DELETE",
657
+ });
658
+ const request = new Request("http://localhost:3000/test/", {
659
+ method: "DELETE",
660
+ });
661
+ const response = await router.handle({
662
+ request,
663
+ renderPage: deps.mockRenderPage,
664
+ getRequestInfo: deps.getRequestInfo,
665
+ onError: deps.onError,
666
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
667
+ rscActionHandler: deps.mockRscActionHandler,
668
+ });
669
+ expect(response.status).toBe(405);
670
+ expect(await response.text()).toBe("Method Not Allowed");
671
+ expect(response.headers.get("Allow")).toBe("GET, OPTIONS, POST");
672
+ });
673
+ it("should handle OPTIONS request with Allow header", async () => {
674
+ const router = defineRoutes([
675
+ route("/test/", {
676
+ get: () => new Response("GET Response"),
677
+ post: () => new Response("POST Response"),
678
+ }),
679
+ ]);
680
+ const deps = createMockDependencies();
681
+ deps.mockRequestInfo.request = new Request("http://localhost:3000/test/", {
682
+ method: "OPTIONS",
683
+ });
684
+ const request = new Request("http://localhost:3000/test/", {
685
+ method: "OPTIONS",
686
+ });
687
+ const response = await router.handle({
688
+ request,
689
+ renderPage: deps.mockRenderPage,
690
+ getRequestInfo: deps.getRequestInfo,
691
+ onError: deps.onError,
692
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
693
+ rscActionHandler: deps.mockRscActionHandler,
694
+ });
695
+ expect(response.status).toBe(204);
696
+ expect(response.headers.get("Allow")).toBe("GET, OPTIONS, POST");
697
+ });
698
+ it("should support custom methods (case-insensitive)", async () => {
699
+ const router = defineRoutes([
700
+ route("/test/", {
701
+ custom: {
702
+ report: () => new Response("REPORT Response"),
703
+ },
704
+ }),
705
+ ]);
706
+ const deps = createMockDependencies();
707
+ deps.mockRequestInfo.request = new Request("http://localhost:3000/test/", {
708
+ method: "REPORT",
709
+ });
710
+ const request = new Request("http://localhost:3000/test/", {
711
+ method: "REPORT",
712
+ });
713
+ const response = await router.handle({
714
+ request,
715
+ renderPage: deps.mockRenderPage,
716
+ getRequestInfo: deps.getRequestInfo,
717
+ onError: deps.onError,
718
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
719
+ rscActionHandler: deps.mockRscActionHandler,
720
+ });
721
+ expect(await response.text()).toBe("REPORT Response");
722
+ });
723
+ it("should normalize custom method keys to lowercase", async () => {
724
+ const router = defineRoutes([
725
+ route("/test/", {
726
+ custom: {
727
+ REPORT: () => new Response("REPORT Response"),
728
+ },
729
+ }),
730
+ ]);
731
+ const deps = createMockDependencies();
732
+ deps.mockRequestInfo.request = new Request("http://localhost:3000/test/", {
733
+ method: "report",
734
+ });
735
+ const request = new Request("http://localhost:3000/test/", {
736
+ method: "report",
737
+ });
738
+ const response = await router.handle({
739
+ request,
740
+ renderPage: deps.mockRenderPage,
741
+ getRequestInfo: deps.getRequestInfo,
742
+ onError: deps.onError,
743
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
744
+ rscActionHandler: deps.mockRscActionHandler,
745
+ });
746
+ expect(await response.text()).toBe("REPORT Response");
747
+ });
748
+ it("should disable 405 when config.disable405 is true", async () => {
749
+ const router = defineRoutes([
750
+ route("/test/", {
751
+ get: () => new Response("GET Response"),
752
+ config: {
753
+ disable405: true,
754
+ },
755
+ }),
756
+ ]);
757
+ const deps = createMockDependencies();
758
+ deps.mockRequestInfo.request = new Request("http://localhost:3000/test/", {
759
+ method: "POST",
760
+ });
761
+ const request = new Request("http://localhost:3000/test/", {
762
+ method: "POST",
763
+ });
764
+ const response = await router.handle({
765
+ request,
766
+ renderPage: deps.mockRenderPage,
767
+ getRequestInfo: deps.getRequestInfo,
768
+ onError: deps.onError,
769
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
770
+ rscActionHandler: deps.mockRscActionHandler,
771
+ });
772
+ expect(response.status).toBe(404);
773
+ expect(await response.text()).toBe("Not Found");
774
+ });
775
+ it("should disable OPTIONS when config.disableOptions is true", async () => {
776
+ const router = defineRoutes([
777
+ route("/test/", {
778
+ get: () => new Response("GET Response"),
779
+ post: () => new Response("POST Response"),
780
+ config: {
781
+ disableOptions: true,
782
+ },
783
+ }),
784
+ ]);
785
+ const deps = createMockDependencies();
786
+ deps.mockRequestInfo.request = new Request("http://localhost:3000/test/", {
787
+ method: "OPTIONS",
788
+ });
789
+ const request = new Request("http://localhost:3000/test/", {
790
+ method: "OPTIONS",
791
+ });
792
+ const response = await router.handle({
793
+ request,
794
+ renderPage: deps.mockRenderPage,
795
+ getRequestInfo: deps.getRequestInfo,
796
+ onError: deps.onError,
797
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
798
+ rscActionHandler: deps.mockRscActionHandler,
799
+ });
800
+ expect(response.status).toBe(405);
801
+ expect(await response.text()).toBe("Method Not Allowed");
802
+ expect(response.headers.get("Allow")).toBe("GET, POST");
803
+ });
804
+ it("should support middleware arrays in method handlers", async () => {
805
+ const executionOrder = [];
806
+ const authMiddleware = () => {
807
+ executionOrder.push("authMiddleware");
808
+ };
809
+ const getHandler = () => {
810
+ executionOrder.push("getHandler");
811
+ return new Response("GET Response");
812
+ };
813
+ const router = defineRoutes([
814
+ route("/test/", {
815
+ get: [authMiddleware, getHandler],
816
+ }),
817
+ ]);
818
+ const deps = createMockDependencies();
819
+ deps.mockRequestInfo.request = new Request("http://localhost:3000/test/", {
820
+ method: "GET",
821
+ });
822
+ const request = new Request("http://localhost:3000/test/", {
823
+ method: "GET",
824
+ });
825
+ const response = await router.handle({
826
+ request,
827
+ renderPage: deps.mockRenderPage,
828
+ getRequestInfo: deps.getRequestInfo,
829
+ onError: deps.onError,
830
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
831
+ rscActionHandler: deps.mockRscActionHandler,
832
+ });
833
+ expect(executionOrder).toEqual(["authMiddleware", "getHandler"]);
834
+ expect(await response.text()).toBe("GET Response");
835
+ });
836
+ });
598
837
  describe("Edge Cases", () => {
599
838
  it("should handle middleware-only apps with RSC actions", async () => {
600
839
  const executionOrder = [];