rwsdk 1.2.8 → 1.2.9

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.
@@ -4,6 +4,10 @@ import { SmokeTestOptions } from "./types.mjs";
4
4
  * Launch a browser instance
5
5
  */
6
6
  export declare function launchBrowser(browserPath?: string, headless?: boolean): Promise<Browser>;
7
+ /**
8
+ * Check if a server is up, trying localhost and loopback host variants.
9
+ */
10
+ export declare function checkServerUp(baseUrl: string, customPath?: string, retries?: number, includeRoot?: boolean): Promise<string>;
7
11
  /**
8
12
  * Get the browser executable path
9
13
  */
@@ -2,7 +2,9 @@ import { computeExecutablePath, detectBrowserPlatform, install, Browser as Puppe
2
2
  import debug from "debug";
3
3
  import { mkdirp, pathExists } from "fs-extra";
4
4
  import { join } from "path";
5
+ import { chromium as playwrightChromium } from "playwright-core";
5
6
  import puppeteer from "puppeteer-core";
7
+ import { $ } from "../$.mjs";
6
8
  import { ensureTmpDir } from "./utils.mjs";
7
9
  const log = debug("rwsdk:e2e:browser");
8
10
  /**
@@ -26,6 +28,52 @@ export async function launchBrowser(browserPath, headless = true) {
26
28
  args: ["--no-sandbox", "--disable-setuid-sandbox"],
27
29
  });
28
30
  }
31
+ /**
32
+ * Check if a server is up, trying localhost and loopback host variants.
33
+ */
34
+ export async function checkServerUp(baseUrl, customPath = "/", retries = 30, includeRoot = true) {
35
+ const pathsToCheck = includeRoot ? ["/"] : [];
36
+ if (customPath !== "/" && customPath !== "") {
37
+ pathsToCheck.push(customPath);
38
+ }
39
+ if (pathsToCheck.length === 0) {
40
+ pathsToCheck.push("/");
41
+ }
42
+ for (const path of pathsToCheck) {
43
+ const normalizedPath = path.startsWith("/") ? path : "/" + path;
44
+ const baseUrlObject = new URL(baseUrl);
45
+ const candidateBaseUrls = Array.from(new Set([
46
+ baseUrl,
47
+ `${baseUrlObject.protocol}//127.0.0.1:${baseUrlObject.port}`,
48
+ `${baseUrlObject.protocol}//[::1]:${baseUrlObject.port}`,
49
+ ]));
50
+ const candidateUrls = candidateBaseUrls.map((candidateBaseUrl) => candidateBaseUrl + normalizedPath);
51
+ log("Checking if server is up at %s (max retries: %d)", candidateUrls[0], retries);
52
+ for (let i = 0; i < retries; i++) {
53
+ for (const candidateUrl of candidateUrls) {
54
+ try {
55
+ log("Attempt %d/%d to check server at %s", i + 1, retries, candidateUrl);
56
+ console.log(`Checking if server is up at ${candidateUrl} (attempt ${i + 1}/${retries})...`);
57
+ await $ `curl --max-time 1 -s -o /dev/null -w "%{http_code}" ${candidateUrl}`;
58
+ log("Server is up at %s", candidateUrl);
59
+ console.log(`✅ Server is up at ${candidateUrl}`);
60
+ return candidateUrl;
61
+ }
62
+ catch {
63
+ // Try the next host variant.
64
+ }
65
+ }
66
+ if (i === retries - 1) {
67
+ log("ERROR: Server at %s did not become available after %d attempts", candidateUrls[0], retries);
68
+ throw new Error(`Server at ${candidateUrls[0]} did not become available after ${retries} attempts`);
69
+ }
70
+ log("Server not up yet, retrying in 2 seconds");
71
+ console.log(`Server not up yet, retrying in 2 seconds...`);
72
+ await new Promise((resolve) => setTimeout(() => resolve(), 2000));
73
+ }
74
+ }
75
+ return "";
76
+ }
29
77
  /**
30
78
  * Get the browser executable path
31
79
  */
@@ -36,6 +84,16 @@ export async function getBrowserPath(testOptions) {
36
84
  console.log(`Using Chrome from environment variable: ${process.env.CHROME_PATH}`);
37
85
  return process.env.CHROME_PATH;
38
86
  }
87
+ try {
88
+ const playwrightPath = playwrightChromium.executablePath();
89
+ if (await pathExists(playwrightPath)) {
90
+ console.log(`Found Playwright Chrome at: ${playwrightPath}`);
91
+ return playwrightPath;
92
+ }
93
+ }
94
+ catch (error) {
95
+ log("Playwright browser path unavailable: %O", error);
96
+ }
39
97
  // Detect platform
40
98
  log("Detecting platform");
41
99
  const platform = detectBrowserPlatform();
@@ -1,4 +1,5 @@
1
1
  export declare const IS_CI: boolean;
2
+ export declare const IS_PULL_REQUEST: boolean;
2
3
  export declare const RWSDK_SKIP_DEV: boolean;
3
4
  export declare const RWSDK_SKIP_DEPLOY: boolean;
4
5
  export declare const IS_DEBUG_MODE: boolean;
@@ -6,6 +7,7 @@ export declare const SETUP_PLAYGROUND_ENV_TIMEOUT: number;
6
7
  export declare const DEPLOYMENT_TIMEOUT: number;
7
8
  export declare const DEPLOYMENT_MIN_TRIES: number;
8
9
  export declare const DEPLOYMENT_CHECK_TIMEOUT: number;
10
+ export declare const PREVIEW_SERVER_TIMEOUT: number;
9
11
  export declare const PUPPETEER_TIMEOUT: number;
10
12
  export declare const HYDRATION_TIMEOUT: number;
11
13
  export declare const DEV_SERVER_TIMEOUT: number;
@@ -5,6 +5,7 @@ export const IS_CI = !!((process.env.CI && !process.env.NOT_CI) ||
5
5
  process.env.TRAVIS ||
6
6
  process.env.JENKINS_URL ||
7
7
  process.env.NETLIFY);
8
+ export const IS_PULL_REQUEST = process.env.GITHUB_EVENT_NAME === "pull_request";
8
9
  export const RWSDK_SKIP_DEV = Boolean(process.env.RWSDK_SKIP_DEV);
9
10
  export const RWSDK_SKIP_DEPLOY = process.env.RWSDK_SKIP_DEPLOY === "true" ||
10
11
  process.env.RWSDK_SKIP_DEPLOY === "1";
@@ -33,6 +34,11 @@ export const DEPLOYMENT_CHECK_TIMEOUT = process.env
33
34
  : IS_DEBUG_MODE
34
35
  ? 30 * 1000
35
36
  : 5 * 60 * 1000;
37
+ export const PREVIEW_SERVER_TIMEOUT = process.env.RWSDK_PREVIEW_SERVER_TIMEOUT
38
+ ? parseInt(process.env.RWSDK_PREVIEW_SERVER_TIMEOUT, 10)
39
+ : IS_DEBUG_MODE
40
+ ? 60 * 1000
41
+ : 10 * 60 * 1000;
36
42
  export const PUPPETEER_TIMEOUT = process.env.RWSDK_PUPPETEER_TIMEOUT
37
43
  ? parseInt(process.env.RWSDK_PUPPETEER_TIMEOUT, 10)
38
44
  : IS_DEBUG_MODE
@@ -1,7 +1,7 @@
1
1
  import debug from "debug";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
3
  import { $, $sh } from "../../lib/$.mjs";
4
- import { poll } from "./poll.mjs";
4
+ import { checkServerUp } from "./browser.mjs";
5
5
  import { IS_DEBUG_MODE } from "./constants.mjs";
6
6
  const DEV_SERVER_CHECK_TIMEOUT = process.env.RWSDK_DEV_SERVER_CHECK_TIMEOUT
7
7
  ? parseInt(process.env.RWSDK_DEV_SERVER_CHECK_TIMEOUT, 10)
@@ -205,23 +205,12 @@ export async function runDevServer(packageManager = "pnpm", cwd) {
205
205
  // Wait for the URL
206
206
  const serverUrl = await waitForUrl();
207
207
  console.log(`✅ Development server started at ${serverUrl}`);
208
- // Poll the URL to ensure it's live before proceeding
209
- await poll(async () => {
210
- try {
211
- const response = await fetch(serverUrl, {
212
- signal: AbortSignal.timeout(1000),
213
- });
214
- // We consider any response (even 4xx or 5xx) as success,
215
- // as it means the worker is routable.
216
- return response.status > 0;
217
- }
218
- catch (e) {
219
- return false;
220
- }
221
- }, {
222
- timeout: DEV_SERVER_CHECK_TIMEOUT,
223
- });
224
- return { url: serverUrl, stopDev };
208
+ // context(justinvdm, 2026-05-13): Probe the debug endpoint instead of
209
+ // the app root because the root response can take several seconds to wake
210
+ // up even after Vite prints its local URL.
211
+ const readinessRetries = Math.max(1, Math.ceil(DEV_SERVER_CHECK_TIMEOUT / 2000));
212
+ const reachableDebugUrl = await checkServerUp(serverUrl, "/__debug", readinessRetries, false);
213
+ return { url: new URL(reachableDebugUrl).origin, stopDev };
225
214
  }
226
215
  catch (error) {
227
216
  // Make sure to try to stop the server on error
@@ -36,6 +36,13 @@ export declare function runRelease(cwd: string, projectDir: string, resourceUniq
36
36
  url: string;
37
37
  workerName: string;
38
38
  }>;
39
+ /**
40
+ * Run a local production preview server (build + preview) and return the URL.
41
+ */
42
+ export declare function runPreviewServer(packageManager?: string, cwd?: string): Promise<{
43
+ url: string;
44
+ stopPreview: () => Promise<void>;
45
+ }>;
39
46
  /**
40
47
  * Check if a resource name includes a specific resource unique key
41
48
  * This is used to identify resources created during our tests
@@ -5,10 +5,11 @@ import { pathExists } from "fs-extra";
5
5
  import * as fs from "fs/promises";
6
6
  import { parse as parseJsonc } from "jsonc-parser";
7
7
  import { setTimeout } from "node:timers/promises";
8
- import { basename, dirname, join, resolve } from "path";
8
+ import { basename, dirname, join, relative, resolve } from "path";
9
9
  import { $ } from "../../lib/$.mjs";
10
+ import { checkServerUp } from "./browser.mjs";
10
11
  import { extractLastJson, parseJson } from "../../lib/jsonUtils.mjs";
11
- import { IS_DEBUG_MODE } from "./constants.mjs";
12
+ import { IS_DEBUG_MODE, PREVIEW_SERVER_TIMEOUT } from "./constants.mjs";
12
13
  const log = debug("rwsdk:e2e:release");
13
14
  /**
14
15
  * Find wrangler cache by searching up the directory tree for node_modules/.cache/wrangler
@@ -400,6 +401,81 @@ export async function runRelease(cwd, projectDir, resourceUniqueKey) {
400
401
  throw error;
401
402
  }
402
403
  }
404
+ /**
405
+ * Run a local production preview server (build + preview) and return the URL.
406
+ */
407
+ export async function runPreviewServer(packageManager = "pnpm", cwd) {
408
+ console.log("🚀 Building for production preview...");
409
+ const pm = packageManager === "yarn-classic" ? "yarn" : packageManager;
410
+ await $(pm, ["run", "build"], {
411
+ cwd: cwd || process.cwd(),
412
+ stdio: "pipe",
413
+ env: { ...process.env, NODE_ENV: "production" },
414
+ });
415
+ console.log("✅ Build complete. Starting preview server...");
416
+ let previewProcess = null;
417
+ let isErrorExpected = false;
418
+ const stopPreview = async () => {
419
+ isErrorExpected = true;
420
+ if (!previewProcess || !previewProcess.pid) {
421
+ return;
422
+ }
423
+ console.log("Stopping preview server...");
424
+ if (process.platform !== "win32") {
425
+ try {
426
+ process.kill(-previewProcess.pid, "SIGKILL");
427
+ }
428
+ catch { }
429
+ }
430
+ await previewProcess.catch(() => { });
431
+ console.log("Preview server stopped");
432
+ };
433
+ previewProcess = $(pm, ["run", "preview", "--", "--port", "4173", "--strictPort"], {
434
+ all: true,
435
+ detached: process.platform !== "win32",
436
+ cleanup: true,
437
+ forceKillAfterTimeout: 2000,
438
+ cwd: cwd || process.cwd(),
439
+ env: { ...process.env, NODE_ENV: "production" },
440
+ stdio: "pipe",
441
+ });
442
+ previewProcess.catch((error) => {
443
+ if (!isErrorExpected) {
444
+ log("Preview server process exited unexpectedly: %O", error);
445
+ }
446
+ });
447
+ const ensurePreviewDeployConfig = async () => {
448
+ const deployConfigPath = resolve(cwd || process.cwd(), ".wrangler/deploy/config.json");
449
+ if (await pathExists(deployConfigPath)) {
450
+ return;
451
+ }
452
+ let workerConfigPath = null;
453
+ for (const candidate of ["wrangler.jsonc", "wrangler.json"]) {
454
+ const resolvedCandidate = resolve(cwd || process.cwd(), candidate);
455
+ if (await pathExists(resolvedCandidate)) {
456
+ workerConfigPath = resolvedCandidate;
457
+ break;
458
+ }
459
+ }
460
+ if (!workerConfigPath) {
461
+ throw new Error(`Unable to create preview deploy config because no wrangler.jsonc or wrangler.json was found in ${cwd || process.cwd()}`);
462
+ }
463
+ await fs.mkdir(dirname(deployConfigPath), { recursive: true });
464
+ const deployConfig = {
465
+ configPath: relative(dirname(deployConfigPath), workerConfigPath),
466
+ auxiliaryWorkers: [],
467
+ };
468
+ await fs.writeFile(deployConfigPath, JSON.stringify(deployConfig, null, 2));
469
+ };
470
+ await ensurePreviewDeployConfig();
471
+ // context(justinvdm, 2026-05-13): Give the CI preview path the same
472
+ // readiness budget as the dev server so local agent-ci runs can absorb build
473
+ // and startup latency without falling back to Cloudflare.
474
+ const reachableDebugUrl = await checkServerUp("http://localhost:4173", "/__debug", Math.max(1, Math.ceil(PREVIEW_SERVER_TIMEOUT / 2000)), false);
475
+ const serverUrl = new URL(reachableDebugUrl).origin;
476
+ console.log(`✅ Preview server started at ${serverUrl}`);
477
+ return { url: serverUrl, stopPreview };
478
+ }
403
479
  /**
404
480
  * Check if a resource name includes a specific resource unique key
405
481
  * This is used to identify resources created during our tests
@@ -3,6 +3,7 @@ import { $ } from "execa";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { ROOT_DIR } from "../constants.mjs";
6
+ import { IS_CI } from "./constants.mjs";
6
7
  import { copyProjectToTempDir } from "./environment.mjs";
7
8
  const log = (message) => console.log(message);
8
9
  /**
@@ -51,7 +52,9 @@ async function copyWranglerCache(targetDir, sdkRoot) {
51
52
  log(` ✅ Wrangler cache copied successfully`);
52
53
  }
53
54
  else {
54
- log(` ⚠️ No wrangler cache found in monorepo, deployment tests may require authentication`);
55
+ log(IS_CI
56
+ ? ` ℹ️ No wrangler cache found in monorepo; CI preview tests do not require it`
57
+ : ` ⚠️ No wrangler cache found in monorepo, deployment tests may require authentication`);
55
58
  }
56
59
  }
57
60
  catch (error) {
@@ -66,13 +66,7 @@ export declare function createDevServer(): {
66
66
  */
67
67
  export declare function createDeployment(): {
68
68
  projectDir: string;
69
- start: () => Promise<{
70
- url: string;
71
- workerName: string;
72
- resourceUniqueKey: string;
73
- projectDir: string;
74
- cleanup: () => Promise<void>;
75
- }>;
69
+ start: () => Promise<DeploymentInstance>;
76
70
  };
77
71
  /**
78
72
  * Executes a test function with a retry mechanism for specific error codes.
@@ -4,10 +4,10 @@ import puppeteer from "puppeteer-core";
4
4
  import { fileURLToPath } from "url";
5
5
  import { afterAll, afterEach, beforeAll, beforeEach, describe, test, } from "vitest";
6
6
  import { launchBrowser } from "./browser.mjs";
7
- import { DEPLOYMENT_CHECK_TIMEOUT, DEPLOYMENT_MIN_TRIES, DEPLOYMENT_TIMEOUT, DEV_SERVER_MIN_TRIES, DEV_SERVER_TIMEOUT, HYDRATION_TIMEOUT, INSTALL_DEPENDENCIES_RETRIES, PUPPETEER_TIMEOUT, SETUP_PLAYGROUND_ENV_TIMEOUT, SETUP_WAIT_TIMEOUT, TEST_MAX_RETRIES, TEST_MAX_RETRIES_PER_CODE, } from "./constants.mjs";
7
+ import { DEPLOYMENT_CHECK_TIMEOUT, DEPLOYMENT_MIN_TRIES, DEPLOYMENT_TIMEOUT, DEV_SERVER_MIN_TRIES, DEV_SERVER_TIMEOUT, HYDRATION_TIMEOUT, INSTALL_DEPENDENCIES_RETRIES, IS_PULL_REQUEST, PUPPETEER_TIMEOUT, SETUP_PLAYGROUND_ENV_TIMEOUT, SETUP_WAIT_TIMEOUT, TEST_MAX_RETRIES, TEST_MAX_RETRIES_PER_CODE, } from "./constants.mjs";
8
8
  import { runDevServer } from "./dev.mjs";
9
9
  import { poll, pollValue } from "./poll.mjs";
10
- import { deleteD1Database, deleteWorker, isRelatedToTest, runRelease, } from "./release.mjs";
10
+ import { deleteD1Database, deleteWorker, isRelatedToTest, runPreviewServer, runRelease, } from "./release.mjs";
11
11
  import { setupTarballEnvironment } from "./tarball.mjs";
12
12
  import { ensureTmpDir } from "./utils.mjs";
13
13
  export { DEPLOYMENT_CHECK_TIMEOUT, DEPLOYMENT_MIN_TRIES, DEPLOYMENT_TIMEOUT, DEV_SERVER_MIN_TRIES, DEV_SERVER_TIMEOUT, HYDRATION_TIMEOUT, INSTALL_DEPENDENCIES_RETRIES, PUPPETEER_TIMEOUT, SETUP_PLAYGROUND_ENV_TIMEOUT, SETUP_WAIT_TIMEOUT, TEST_MAX_RETRIES, TEST_MAX_RETRIES_PER_CODE, };
@@ -221,15 +221,34 @@ export function createDeployment() {
221
221
  if (SKIP_DEPLOYMENT_TESTS) {
222
222
  throw new Error("Deployment tests are skipped via RWSDK_SKIP_DEPLOY=1");
223
223
  }
224
+ const dirName = basename(projectDir);
225
+ // Match formats: {projectName}-t-{hash}, {projectName}-test-{hash}, or {projectName}-e2e-test-{hash}
226
+ const match = dirName.match(/-t-([a-f0-9]+)$/) ||
227
+ dirName.match(/-test-([a-f0-9]+)$/) ||
228
+ dirName.match(/-e2e-test-([a-f0-9]+)$/);
229
+ const resourceUniqueKey = match
230
+ ? match[1]
231
+ : Math.random().toString(36).substring(2, 15);
232
+ if (IS_PULL_REQUEST) {
233
+ console.log("PR mode detected — using local preview instead of deploy");
234
+ const previewResult = await runPreviewServer(process.env.PACKAGE_MANAGER ||
235
+ "pnpm", projectDir);
236
+ instance = {
237
+ url: previewResult.url,
238
+ workerName: `preview-${resourceUniqueKey}`,
239
+ resourceUniqueKey,
240
+ projectDir,
241
+ cleanup: async () => {
242
+ await previewResult.stopPreview().catch((error) => {
243
+ console.warn(`Warning: Background preview cleanup failed: ${error.message}`);
244
+ });
245
+ return Promise.resolve();
246
+ },
247
+ };
248
+ deploymentInstances.push(instance);
249
+ return instance;
250
+ }
224
251
  const newInstance = await pollValue(async () => {
225
- const dirName = basename(projectDir);
226
- // Match formats: {projectName}-t-{hash}, {projectName}-test-{hash}, or {projectName}-e2e-test-{hash}
227
- const match = dirName.match(/-t-([a-f0-9]+)$/) ||
228
- dirName.match(/-test-([a-f0-9]+)$/) ||
229
- dirName.match(/-e2e-test-([a-f0-9]+)$/);
230
- const resourceUniqueKey = match
231
- ? match[1]
232
- : Math.random().toString(36).substring(2, 15);
233
252
  const deployResult = await runRelease(projectDir, projectDir, resourceUniqueKey);
234
253
  // A fresh *.workers.dev subdomain can return 200 with Cloudflare's
235
254
  // "There is nothing here yet" placeholder before the worker code
@@ -273,6 +292,7 @@ export function createDeployment() {
273
292
  console.log(`Retrying deployment creation (attempt ${tries})... Error: ${error.message}`);
274
293
  },
275
294
  });
295
+ instance = newInstance;
276
296
  deploymentInstances.push(newInstance);
277
297
  return newInstance;
278
298
  },
@@ -37,7 +37,7 @@ bail?: boolean): Promise<void>;
37
37
  /**
38
38
  * DRY: checkServerUp now checks both root and custom path if needed
39
39
  */
40
- export declare function checkServerUp(baseUrl: string, customPath?: string, retries?: number, bail?: boolean): Promise<boolean>;
40
+ export declare function checkServerUp(baseUrl: string, customPath?: string, retries?: number, bail?: boolean): Promise<string>;
41
41
  /**
42
42
  * HMR test for server component
43
43
  * Updates the server component and verifies that HMR applies the changes
@@ -749,42 +749,48 @@ export async function checkServerUp(baseUrl, customPath = "/", retries = RETRIES
749
749
  pathsToCheck.push(customPath);
750
750
  }
751
751
  for (const path of pathsToCheck) {
752
- const url = baseUrl + (path.startsWith("/") ? path : "/" + path);
753
- log("Checking if server is up at %s (max retries: %d)", url, retries);
754
- let up = false;
752
+ const normalizedPath = path.startsWith("/") ? path : "/" + path;
753
+ const baseUrlObject = new URL(baseUrl);
754
+ const candidateBaseUrls = Array.from(new Set([
755
+ baseUrl,
756
+ `${baseUrlObject.protocol}//127.0.0.1:${baseUrlObject.port}`,
757
+ `${baseUrlObject.protocol}//[::1]:${baseUrlObject.port}`,
758
+ ]));
759
+ const candidateUrls = candidateBaseUrls.map((candidateBaseUrl) => candidateBaseUrl + normalizedPath);
760
+ log("Checking if server is up at %s (max retries: %d)", candidateUrls[0], retries);
755
761
  for (let i = 0; i < retries; i++) {
756
- try {
757
- log("Attempt %d/%d to check server at %s", i + 1, retries, url);
758
- console.log(`Checking if server is up at ${url} (attempt ${i + 1}/${retries})...`);
759
- await $ `curl -s -o /dev/null -w "%{http_code}" ${url}`;
760
- log("Server is up at %s", url);
761
- console.log(`✅ Server is up at ${url}`);
762
- up = true;
763
- break;
762
+ for (const candidateUrl of candidateUrls) {
763
+ try {
764
+ log("Attempt %d/%d to check server at %s", i + 1, retries, candidateUrl);
765
+ console.log(`Checking if server is up at ${candidateUrl} (attempt ${i + 1}/${retries})...`);
766
+ await $ `curl --max-time 1 -s -o /dev/null -w "%{http_code}" ${candidateUrl}`;
767
+ log("Server is up at %s", candidateUrl);
768
+ console.log(`✅ Server is up at ${candidateUrl}`);
769
+ return candidateUrl;
770
+ }
771
+ catch {
772
+ // Try the next host variant.
773
+ }
764
774
  }
765
- catch (error) {
766
- if (i === retries - 1) {
767
- log("ERROR: Server at %s did not become available after %d attempts", url, retries);
768
- const errorMessage = `Server at ${url} did not become available after ${retries} attempts`;
769
- if (bail) {
770
- // If bail is true, call fail() which will exit the process
771
- await fail(new Error(errorMessage), 1, `Server Availability Check: ${url}`);
772
- return false; // This will never be reached due to fail() exiting
773
- }
774
- else {
775
- // Otherwise throw an error that can be caught by the caller
776
- throw new Error(errorMessage);
777
- }
775
+ if (i === retries - 1) {
776
+ log("ERROR: Server at %s did not become available after %d attempts", candidateUrls[0], retries);
777
+ const errorMessage = `Server at ${candidateUrls[0]} did not become available after ${retries} attempts`;
778
+ if (bail) {
779
+ // If bail is true, call fail() which will exit the process
780
+ await fail(new Error(errorMessage), 1, `Server Availability Check: ${candidateUrls[0]}`);
781
+ return ""; // This will never be reached due to fail() exiting
782
+ }
783
+ else {
784
+ // Otherwise throw an error that can be caught by the caller
785
+ throw new Error(errorMessage);
778
786
  }
779
- log("Server not up yet, retrying in 2 seconds");
780
- console.log(`Server not up yet, retrying in 2 seconds...`);
781
- await new Promise((resolve) => setTimeout(() => resolve(), 2000));
782
787
  }
788
+ log("Server not up yet, retrying in 2 seconds");
789
+ console.log(`Server not up yet, retrying in 2 seconds...`);
790
+ await new Promise((resolve) => setTimeout(() => resolve(), 2000));
783
791
  }
784
- if (!up)
785
- return false;
786
792
  }
787
- return true;
793
+ return "";
788
794
  }
789
795
  /**
790
796
  * Perform only the realtime upgrade and tests without doing initial checks
@@ -8,7 +8,14 @@ export declare function runRelease(cwd: string, projectDir: string, resourceUniq
8
8
  url: string;
9
9
  workerName: string;
10
10
  }>;
11
+ /**
12
+ * Run the local preview server (build + preview) and return the URL
13
+ */
14
+ export declare function runPreviewServer(packageManager?: string, cwd?: string): Promise<{
15
+ url: string;
16
+ stopPreview: () => Promise<void>;
17
+ }>;
11
18
  /**
12
19
  * Runs tests against the production deployment
13
20
  */
14
- export declare function runReleaseTest(artifactDir: string, resources: TestResources, browserPath?: string, headless?: boolean, bail?: boolean, skipClient?: boolean, projectDir?: string, realtime?: boolean, skipHmr?: boolean, skipStyleTests?: boolean): Promise<void>;
21
+ export declare function runReleaseTest(artifactDir: string, resources: TestResources, browserPath?: string, headless?: boolean, bail?: boolean, skipClient?: boolean, projectDir?: string, realtime?: boolean, skipHmr?: boolean, skipStyleTests?: boolean, ci?: boolean): Promise<void>;
@@ -1,7 +1,8 @@
1
1
  import { setTimeout } from "node:timers/promises";
2
- import { $expect, deleteD1Database, deleteWorker, isRelatedToTest, listD1Databases, runRelease as runE2ERelease, } from "../../lib/e2e/release.mjs";
2
+ import { $expect, deleteD1Database, deleteWorker, isRelatedToTest, listD1Databases, runPreviewServer as runE2EPreviewServer, runRelease as runE2ERelease, } from "../../lib/e2e/release.mjs";
3
3
  import { checkServerUp, checkUrl } from "./browser.mjs";
4
4
  import { log } from "./constants.mjs";
5
+ import { state } from "./state.mjs";
5
6
  export { $expect, deleteD1Database, deleteWorker, isRelatedToTest, listD1Databases, };
6
7
  /**
7
8
  * Run the release command to deploy to Cloudflare
@@ -9,6 +10,12 @@ export { $expect, deleteD1Database, deleteWorker, isRelatedToTest, listD1Databas
9
10
  export async function runRelease(cwd, projectDir, resourceUniqueKey) {
10
11
  return runE2ERelease(cwd, projectDir, resourceUniqueKey);
11
12
  }
13
+ /**
14
+ * Run the local preview server (build + preview) and return the URL
15
+ */
16
+ export async function runPreviewServer(packageManager = "pnpm", cwd) {
17
+ return runE2EPreviewServer(packageManager, cwd);
18
+ }
12
19
  async function waitForDeploymentContent(baseUrl, { timeoutMs = 60_000, intervalMs = 2_000, } = {}) {
13
20
  const marker = "__RWSDK_CONTEXT";
14
21
  const deadline = Date.now() + timeoutMs;
@@ -41,46 +48,64 @@ async function waitForDeploymentContent(baseUrl, { timeoutMs = 60_000, intervalM
41
48
  /**
42
49
  * Runs tests against the production deployment
43
50
  */
44
- export async function runReleaseTest(artifactDir, resources, browserPath, headless = true, bail = false, skipClient = false, projectDir, realtime = false, skipHmr = false, skipStyleTests = false) {
51
+ export async function runReleaseTest(artifactDir, resources, browserPath, headless = true, bail = false, skipClient = false, projectDir, realtime = false, skipHmr = false, skipStyleTests = false, ci = false) {
45
52
  log("Starting release test");
46
53
  console.log("\n🚀 Testing production deployment");
54
+ let url;
55
+ let stopPreview;
47
56
  try {
48
- log("Running release process");
49
- const { url, workerName } = await runRelease(resources.targetDir || "", projectDir || "", resources.resourceUniqueKey);
50
- // Wait a moment before checking server availability
51
- log("Waiting 1s before checking server...");
52
- await setTimeout(1000);
53
- // DRY: check both root and custom path
54
- await checkServerUp(url, "/");
55
- // A fresh *.workers.dev subdomain can return 200 with Cloudflare's
56
- // "There is nothing here yet" placeholder before the worker code is
57
- // globally propagated. Poll the URL until the response body contains
58
- // an rwsdk-rendered marker so we don't run the browser tests against
59
- // the placeholder.
60
- await waitForDeploymentContent(url);
57
+ if (process.env.GITHUB_EVENT_NAME === "pull_request") {
58
+ log("PR mode detected using local preview instead of deploy");
59
+ const previewResult = await runPreviewServer(state.options.packageManager, resources.targetDir || "");
60
+ url = previewResult.url;
61
+ stopPreview = previewResult.stopPreview;
62
+ }
63
+ else {
64
+ log("Running release process");
65
+ const { url: deployUrl, workerName } = await runRelease(resources.targetDir || "", projectDir || "", resources.resourceUniqueKey);
66
+ url = deployUrl;
67
+ // Wait a moment before checking server availability
68
+ log("Waiting 1s before checking server...");
69
+ await setTimeout(1000);
70
+ // DRY: check both root and custom path
71
+ await checkServerUp(url, "/");
72
+ // A fresh *.workers.dev subdomain can return 200 with Cloudflare's
73
+ // "There is nothing here yet" placeholder before the worker code is
74
+ // globally propagated. Poll the URL until the response body contains
75
+ // an rwsdk-rendered marker so we don't run the browser tests against
76
+ // the placeholder.
77
+ await waitForDeploymentContent(url);
78
+ // Store the worker name if we didn't set it earlier
79
+ if (resources && !resources.workerName) {
80
+ log("Storing worker name: %s", workerName);
81
+ resources.workerName = workerName;
82
+ }
83
+ // Mark that we created this worker during the test
84
+ if (resources) {
85
+ log("Marking worker %s as created during this test", workerName);
86
+ resources.workerCreatedDuringTest = true;
87
+ // Update the global state
88
+ if (resources.workerCreatedDuringTest !== undefined) {
89
+ resources.workerCreatedDuringTest = true;
90
+ }
91
+ }
92
+ }
61
93
  // Now run the tests with the custom path
62
94
  const testUrl = new URL("/__smoke_test", url).toString();
63
95
  await checkUrl(testUrl, artifactDir, browserPath, headless, bail, skipClient, "Production", realtime, resources.targetDir, // Add target directory parameter
64
96
  true, // Always skip HMR in production
65
97
  skipStyleTests);
66
98
  log("Release test completed successfully");
67
- // Store the worker name if we didn't set it earlier
68
- if (resources && !resources.workerName) {
69
- log("Storing worker name: %s", workerName);
70
- resources.workerName = workerName;
71
- }
72
- // Mark that we created this worker during the test
73
- if (resources) {
74
- log("Marking worker %s as created during this test", workerName);
75
- resources.workerCreatedDuringTest = true;
76
- // Update the global state
77
- if (resources.workerCreatedDuringTest !== undefined) {
78
- resources.workerCreatedDuringTest = true;
79
- }
80
- }
81
99
  }
82
100
  catch (error) {
83
101
  log("Error during release testing: %O", error);
84
102
  throw error;
85
103
  }
104
+ finally {
105
+ if (stopPreview) {
106
+ await stopPreview().catch((e) => {
107
+ log("Error stopping preview server: %O", e);
108
+ });
109
+ }
110
+ }
86
111
  }
@@ -95,7 +95,7 @@ export async function runSmokeTests(options = {}) {
95
95
  // Update status when release command runs
96
96
  try {
97
97
  console.log("\n🚀 Running release command smoke test");
98
- await runReleaseTest(options.artifactDir, resources, browserPath, options.headless !== false, options.bail, options.skipClient, options.projectDir, options.realtime, options.skipHmr, options.skipStyleTests);
98
+ await runReleaseTest(options.artifactDir, resources, browserPath, options.headless !== false, options.bail, options.skipClient, options.projectDir, options.realtime, options.skipHmr, options.skipStyleTests, options.ci);
99
99
  // Update release command status to PASSED
100
100
  updateTestStatus("production", "releaseCommand", "PASSED");
101
101
  // Mark that release tests have run successfully
@@ -9,6 +9,7 @@ export type RouteMiddleware<T extends RequestInfo = RequestInfo> = BivariantRout
9
9
  export type ExceptHandler<T extends RequestInfo = RequestInfo> = {
10
10
  __rwExcept: true;
11
11
  handler: (error: unknown, requestInfo: T) => MaybePromise<React.JSX.Element | Response | void>;
12
+ pathPattern?: string;
12
13
  };
13
14
  type RouteFunction<T extends RequestInfo = RequestInfo> = BivariantRouteHandler<T, MaybePromise<Response>>;
14
15
  type RouteComponent<T extends RequestInfo = RequestInfo> = BivariantRouteHandler<T, MaybePromise<React.JSX.Element | Response | void>>;
@@ -185,11 +185,28 @@ export function defineRoutes(routes) {
185
185
  function isExceptHandler(route) {
186
186
  return route.type === "except";
187
187
  }
188
+ function isPathInExceptScope(pathPattern, requestPath) {
189
+ const normalized = pathPattern.endsWith("/")
190
+ ? pathPattern
191
+ : pathPattern + "/";
192
+ const hasParams = normalized.includes(":") || normalized.includes("*");
193
+ if (hasParams) {
194
+ const wildcardPattern = normalized.slice(0, -1) + "/*";
195
+ return (matchPath(wildcardPattern, requestPath) !== null ||
196
+ matchPath(normalized.slice(0, -1), requestPath) !== null);
197
+ }
198
+ return (requestPath === normalized || requestPath.startsWith(normalized));
199
+ }
188
200
  async function executeExceptHandlers(error, startIndex) {
189
201
  // Search backwards from startIndex to find the most recent except handler
190
202
  for (let i = startIndex; i >= 0; i--) {
191
203
  const route = compiledRoutes[i];
192
204
  if (isExceptHandler(route)) {
205
+ const pattern = route.handler.pathPattern;
206
+ if (pattern &&
207
+ !isPathInExceptScope(pattern, getRequestInfo().path)) {
208
+ continue;
209
+ }
193
210
  try {
194
211
  const result = await route.handler.handler(error, getRequestInfo());
195
212
  const handled = await handleMiddlewareResult(result);
@@ -554,8 +571,16 @@ export function prefix(prefixPath, routes) {
554
571
  r !== null &&
555
572
  "__rwExcept" in r &&
556
573
  r.__rwExcept === true) {
557
- // Pass through ExceptHandler as-is
558
- return r;
574
+ const existing = r.pathPattern;
575
+ const combined = existing
576
+ ? joinPaths(normalizedPrefix, existing)
577
+ : normalizedPrefix;
578
+ const scoped = {
579
+ __rwExcept: true,
580
+ handler: r.handler,
581
+ pathPattern: combined,
582
+ };
583
+ return scoped;
559
584
  }
560
585
  if (Array.isArray(r)) {
561
586
  // Recursively process nested route arrays
@@ -1535,5 +1535,101 @@ describe("defineRoutes - Request Handling Behavior", () => {
1535
1535
  expect(response.status).toBe(500);
1536
1536
  expect(await response.text()).toBe(`Caught: ${errorMessage}`);
1537
1537
  });
1538
+ describe("prefix scoping", () => {
1539
+ const makePrefixApp = () => {
1540
+ const globalHandler = except(() => new Response("[GLOBAL]", {
1541
+ status: 500,
1542
+ }));
1543
+ const adminHandler = except(() => new Response("[ADMIN]", {
1544
+ status: 500,
1545
+ }));
1546
+ const router = defineRoutes([
1547
+ globalHandler,
1548
+ ...prefix("/admin", [
1549
+ adminHandler,
1550
+ route("/dashboard/", () => {
1551
+ throw new Error("admin dashboard error");
1552
+ }),
1553
+ ]),
1554
+ route("/", () => {
1555
+ throw new Error("home route error");
1556
+ }),
1557
+ ]);
1558
+ return router;
1559
+ };
1560
+ const handle = async (router, path) => {
1561
+ const deps = createMockDependencies();
1562
+ deps.mockRequestInfo.request = new Request(`http://localhost:3000${path}`);
1563
+ return router.handle({
1564
+ request: new Request(`http://localhost:3000${path}`),
1565
+ renderPage: deps.mockRenderPage,
1566
+ getRequestInfo: deps.getRequestInfo,
1567
+ onError: deps.onError,
1568
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
1569
+ rscActionHandler: deps.mockRscActionHandler,
1570
+ });
1571
+ };
1572
+ it("an except inside prefix should NOT catch errors from routes outside the prefix", async () => {
1573
+ const router = makePrefixApp();
1574
+ const response = await handle(router, "/");
1575
+ expect(await response.text()).toBe("[GLOBAL]");
1576
+ });
1577
+ it("an except inside prefix should catch errors from routes inside the prefix", async () => {
1578
+ const router = makePrefixApp();
1579
+ const response = await handle(router, "/admin/dashboard/");
1580
+ expect(await response.text()).toBe("[ADMIN]");
1581
+ });
1582
+ it("an except inside prefix that returns void should bubble to the global handler", async () => {
1583
+ const router = defineRoutes([
1584
+ except(() => new Response("[GLOBAL]", { status: 500 })),
1585
+ ...prefix("/admin", [
1586
+ except(() => undefined),
1587
+ route("/dashboard/", () => {
1588
+ throw new Error("boom");
1589
+ }),
1590
+ ]),
1591
+ ]);
1592
+ const response = await handle(router, "/admin/dashboard/");
1593
+ expect(await response.text()).toBe("[GLOBAL]");
1594
+ });
1595
+ it("nested prefixes compose the scope path", async () => {
1596
+ const router = defineRoutes([
1597
+ except(() => new Response("[GLOBAL]", { status: 500 })),
1598
+ ...prefix("/a", [
1599
+ ...prefix("/b", [
1600
+ except(() => new Response("[A/B]", { status: 500 })),
1601
+ route("/c/", () => {
1602
+ throw new Error("a/b/c error");
1603
+ }),
1604
+ ]),
1605
+ ]),
1606
+ route("/d/", () => {
1607
+ throw new Error("d error");
1608
+ }),
1609
+ ]);
1610
+ const inside = await handle(router, "/a/b/c/");
1611
+ expect(await inside.text()).toBe("[A/B]");
1612
+ const outside = await handle(router, "/d/");
1613
+ expect(await outside.text()).toBe("[GLOBAL]");
1614
+ });
1615
+ it("parameterized prefixes scope the except handler", async () => {
1616
+ const router = defineRoutes([
1617
+ except(() => new Response("[GLOBAL]", { status: 500 })),
1618
+ ...prefix("/users/:id", [
1619
+ except(() => new Response("[USER]", { status: 500 })),
1620
+ route("/profile/", () => {
1621
+ throw new Error("profile error");
1622
+ }),
1623
+ ]),
1624
+ route("/about/", () => {
1625
+ throw new Error("about error");
1626
+ }),
1627
+ ]);
1628
+ const inside = await handle(router, "/users/42/profile/");
1629
+ expect(await inside.text()).toBe("[USER]");
1630
+ const outside = await handle(router, "/about/");
1631
+ expect(await outside.text()).toBe("[GLOBAL]");
1632
+ });
1633
+ });
1538
1634
  });
1539
1635
  });
@@ -5,6 +5,7 @@ import path from "path";
5
5
  import { pathToFileURL } from "url";
6
6
  import * as vite from "vite";
7
7
  import { createLogger } from "vite";
8
+ import { checkServerUp } from "../lib/e2e/browser.mjs";
8
9
  const debug = dbg("rwsdk:worker-run");
9
10
  const main = async () => {
10
11
  process.env.RWSDK_WORKER_RUN = "1";
@@ -47,14 +48,47 @@ const main = async () => {
47
48
  });
48
49
  await server.listen();
49
50
  const fileUrl = pathToFileURL(scriptPath).href;
50
- const url = `http://localhost:${port}/__worker-run?script=${encodeURIComponent(fileUrl)}`;
51
- debug("Fetching %s", url);
52
- const response = await fetch(url, {
53
- headers: {
54
- "x-rwsdk-worker-run-token": token,
55
- },
56
- });
57
- debug("Response from worker: %s", response);
51
+ const readyUrl = await checkServerUp(`http://localhost:${port}`, "/__debug", 30, false);
52
+ const readyOrigin = new URL(readyUrl).origin;
53
+ const readyOriginUrl = new URL(readyOrigin);
54
+ const candidateBaseUrls = Array.from(new Set([
55
+ readyOrigin,
56
+ `${readyOriginUrl.protocol}//127.0.0.1:${readyOriginUrl.port}`,
57
+ `${readyOriginUrl.protocol}//[::1]:${readyOriginUrl.port}`,
58
+ ]));
59
+ let response;
60
+ let lastFetchError;
61
+ for (const candidateBaseUrl of candidateBaseUrls) {
62
+ const url = `${candidateBaseUrl}/__worker-run?script=${encodeURIComponent(fileUrl)}`;
63
+ const fetchAttempts = 5;
64
+ debug("Fetching %s", url);
65
+ for (let attempt = 0; attempt < fetchAttempts; attempt++) {
66
+ try {
67
+ response = await fetch(url, {
68
+ headers: {
69
+ "x-rwsdk-worker-run-token": token,
70
+ },
71
+ });
72
+ debug("Response from worker: %s", response);
73
+ break;
74
+ }
75
+ catch (error) {
76
+ lastFetchError = error;
77
+ debug("Fetch failed for %s on attempt %d/%d: %O", url, attempt + 1, fetchAttempts, error);
78
+ if (attempt < fetchAttempts - 1) {
79
+ await new Promise((resolve) => {
80
+ setTimeout(() => resolve(), 2000);
81
+ });
82
+ }
83
+ }
84
+ }
85
+ if (response) {
86
+ break;
87
+ }
88
+ }
89
+ if (!response) {
90
+ throw lastFetchError ?? new Error("worker-run fetch failed");
91
+ }
58
92
  if (!response.ok) {
59
93
  const errorText = await response.text();
60
94
  console.error(`Error: worker-run script failed with status ${response.status}.`);
@@ -6,12 +6,15 @@ vi.mock("cloudflare:workers", () => {
6
6
  });
7
7
  import { SyncedStateServer } from "../SyncedStateServer.mjs";
8
8
  const createStub = (onInvoke) => {
9
- const fn = Object.assign(async (value) => {
10
- await onInvoke(value);
11
- }, {
12
- dup: () => fn,
13
- });
14
- return fn;
9
+ const makeStub = () => {
10
+ const fn = Object.assign(async (value) => {
11
+ await onInvoke(value);
12
+ }, {
13
+ dup: () => makeStub(),
14
+ });
15
+ return fn;
16
+ };
17
+ return makeStub();
15
18
  };
16
19
  describe("SyncedStateServer", () => {
17
20
  it("notifies subscribers when state changes", async () => {
@@ -27,11 +30,15 @@ describe("SyncedStateServer", () => {
27
30
  });
28
31
  it("removes subscriptions on unsubscribe", () => {
29
32
  const coordinator = new SyncedStateServer({}, {});
30
- const stub = createStub(() => { });
33
+ const received = [];
34
+ const stub = createStub((value) => {
35
+ received.push(value);
36
+ });
31
37
  coordinator.subscribe("counter", stub);
32
38
  coordinator.unsubscribe("counter", stub);
33
39
  coordinator.setState(1, "counter");
34
40
  expect(coordinator.getState("counter")).toBe(1);
41
+ expect(received).toEqual([]);
35
42
  });
36
43
  it("drops failing subscribers", async () => {
37
44
  const coordinator = new SyncedStateServer({}, {});
@@ -9,10 +9,14 @@ vi.mock("capnweb", () => ({
9
9
  },
10
10
  newWorkersRpcResponse: vi.fn(),
11
11
  }));
12
- vi.mock("../runtime/entries/router", () => ({
12
+ vi.mock("../../runtime/entries/router", () => ({
13
13
  route: vi.fn((path, handler) => ({ path, handler })),
14
14
  }));
15
- import { SyncedStateServer } from "../SyncedStateServer.mjs";
15
+ vi.mock("../../runtime/requestInfo/worker", () => ({
16
+ runWithRequestInfo: (_requestInfo, fn) => fn(),
17
+ }));
18
+ import { newWorkersRpcResponse } from "capnweb";
19
+ import { syncedStateRoutes, SyncedStateServer } from "../worker.mjs";
16
20
  describe("SyncedStateProxy", () => {
17
21
  let mockCoordinator;
18
22
  beforeEach(() => {
@@ -20,6 +24,7 @@ describe("SyncedStateProxy", () => {
20
24
  });
21
25
  const mockStub = {};
22
26
  afterEach(() => {
27
+ vi.mocked(newWorkersRpcResponse).mockReset();
23
28
  SyncedStateServer.registerKeyHandler(async (key, stub) => key);
24
29
  });
25
30
  it("transforms keys before calling coordinator methods when handler is registered", async () => {
@@ -58,6 +63,40 @@ describe("SyncedStateProxy", () => {
58
63
  SyncedStateServer.registerKeyHandler(handler);
59
64
  await expect(handler("test", mockStub)).rejects.toThrow("Handler error");
60
65
  });
66
+ it("uses the subscribed duplicated RPC client when unsubscribing (#1207)", async () => {
67
+ const coordinator = {
68
+ _setStub: vi.fn(),
69
+ subscribe: vi.fn().mockResolvedValue(undefined),
70
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
71
+ };
72
+ const namespace = {
73
+ idFromName: vi.fn(() => "synced-state-id"),
74
+ get: vi.fn(() => coordinator),
75
+ };
76
+ let proxy;
77
+ vi.mocked(newWorkersRpcResponse).mockImplementation(async (_request, api) => {
78
+ proxy = api;
79
+ return new Response(null, { status: 204 });
80
+ });
81
+ SyncedStateServer.registerKeyHandler(async (key) => key);
82
+ const [baseRoute] = syncedStateRoutes(() => namespace);
83
+ await baseRoute.handler({
84
+ request: new Request("https://example.com/__synced-state"),
85
+ params: {},
86
+ });
87
+ const duplicatedClient = Object.assign(vi.fn(), {
88
+ dup: vi.fn(() => duplicatedClient),
89
+ });
90
+ const originalClient = Object.assign(vi.fn(), {
91
+ dup: vi.fn(() => duplicatedClient),
92
+ });
93
+ await proxy.subscribe("notifications", originalClient);
94
+ await proxy.unsubscribe("notifications", originalClient);
95
+ const subscribeClient = coordinator.subscribe.mock.calls[0][1];
96
+ const unsubscribeClient = coordinator.unsubscribe.mock.calls[0][1];
97
+ expect(subscribeClient).not.toBe(originalClient);
98
+ expect(unsubscribeClient).toBe(subscribeClient);
99
+ });
61
100
  it("handles async operations in handler", async () => {
62
101
  const handler = async (key, stub) => {
63
102
  await new Promise((resolve) => setTimeout(resolve, 5));
@@ -19,7 +19,7 @@ export { SyncedStateServer };
19
19
  const DEFAULT_SYNC_STATE_NAME = "syncedState";
20
20
  let SyncedStateProxyClass = null;
21
21
  async function getSyncedStateProxy() {
22
- var _SyncedStateProxy_instances, _SyncedStateProxy_stub, _SyncedStateProxy_keyHandler, _SyncedStateProxy_requestInfo, _SyncedStateProxy_transformKey, _SyncedStateProxy_callHandler, _a;
22
+ var _SyncedStateProxy_instances, _SyncedStateProxy_stub, _SyncedStateProxy_keyHandler, _SyncedStateProxy_requestInfo, _SyncedStateProxy_subscriptionClients, _SyncedStateProxy_transformKey, _SyncedStateProxy_callHandler, _a;
23
23
  const { RpcTarget, newWorkersRpcResponse } = await loadCapnweb();
24
24
  if (!SyncedStateProxyClass) {
25
25
  SyncedStateProxyClass = (_a = class SyncedStateProxy extends RpcTarget {
@@ -29,6 +29,9 @@ async function getSyncedStateProxy() {
29
29
  _SyncedStateProxy_stub.set(this, void 0);
30
30
  _SyncedStateProxy_keyHandler.set(this, void 0);
31
31
  _SyncedStateProxy_requestInfo.set(this, void 0);
32
+ // Map original RPC callbacks to the duplicated callbacks sent to the DO
33
+ // so unsubscribe uses the same identity that subscribe registered.
34
+ _SyncedStateProxy_subscriptionClients.set(this, new Map());
32
35
  __classPrivateFieldSet(this, _SyncedStateProxy_stub, stub, "f");
33
36
  __classPrivateFieldSet(this, _SyncedStateProxy_keyHandler, keyHandler, "f");
34
37
  __classPrivateFieldSet(this, _SyncedStateProxy_requestInfo, requestInfo, "f");
@@ -54,7 +57,24 @@ async function getSyncedStateProxy() {
54
57
  // dup the client if it is a function; otherwise, pass it as is;
55
58
  // this is because the client is a WebSocketRpcSession, and we need to pass a new instance of the client to the DO;
56
59
  const clientToPass = typeof client.dup === "function" ? client.dup() : client;
57
- return __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").subscribe(transformedKey, clientToPass);
60
+ let clientsForKey = __classPrivateFieldGet(this, _SyncedStateProxy_subscriptionClients, "f").get(transformedKey);
61
+ if (!clientsForKey) {
62
+ clientsForKey = new Map();
63
+ __classPrivateFieldGet(this, _SyncedStateProxy_subscriptionClients, "f").set(transformedKey, clientsForKey);
64
+ }
65
+ clientsForKey.set(client, clientToPass);
66
+ try {
67
+ return await __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").subscribe(transformedKey, clientToPass);
68
+ }
69
+ catch (error) {
70
+ if (clientsForKey.get(client) === clientToPass) {
71
+ clientsForKey.delete(client);
72
+ if (clientsForKey.size === 0) {
73
+ __classPrivateFieldGet(this, _SyncedStateProxy_subscriptionClients, "f").delete(transformedKey);
74
+ }
75
+ }
76
+ throw error;
77
+ }
58
78
  }
59
79
  async unsubscribe(key, client) {
60
80
  const transformedKey = await __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_transformKey).call(this, key);
@@ -65,18 +85,29 @@ async function getSyncedStateProxy() {
65
85
  if (unsubscribeHandler) {
66
86
  __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_callHandler).call(this, unsubscribeHandler, transformedKey, __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f"));
67
87
  }
88
+ const clientsForKey = __classPrivateFieldGet(this, _SyncedStateProxy_subscriptionClients, "f").get(transformedKey);
89
+ const clientToPass = clientsForKey?.get(client) ?? client;
68
90
  try {
69
- await __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").unsubscribe(transformedKey, client);
91
+ await __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").unsubscribe(transformedKey, clientToPass);
70
92
  }
71
93
  catch (error) {
72
94
  // Ignore errors during unsubscribe - handler has already been called
73
95
  // This prevents RPC stub disposal errors from propagating
74
96
  }
97
+ finally {
98
+ if (clientsForKey && clientsForKey.get(client) === clientToPass) {
99
+ clientsForKey.delete(client);
100
+ if (clientsForKey.size === 0) {
101
+ __classPrivateFieldGet(this, _SyncedStateProxy_subscriptionClients, "f").delete(transformedKey);
102
+ }
103
+ }
104
+ }
75
105
  }
76
106
  },
77
107
  _SyncedStateProxy_stub = new WeakMap(),
78
108
  _SyncedStateProxy_keyHandler = new WeakMap(),
79
109
  _SyncedStateProxy_requestInfo = new WeakMap(),
110
+ _SyncedStateProxy_subscriptionClients = new WeakMap(),
80
111
  _SyncedStateProxy_instances = new WeakSet(),
81
112
  _SyncedStateProxy_transformKey =
82
113
  /**
@@ -1,10 +1,11 @@
1
- import jsBeautify from "js-beautify";
1
+ import ts from "typescript";
2
2
  import { beforeEach, describe, expect, it } from "vitest";
3
3
  import stubEnvVars from "../lib/testUtils/stubEnvVars.mjs";
4
4
  import { transformJsxScriptTagsCode } from "./transformJsxScriptTagsPlugin.mjs";
5
- // Helper function to normalize code formatting for test comparisons
5
+ // Helper function to normalize code formatting for test comparisons.
6
6
  function normalizeCode(code) {
7
- return jsBeautify(code, { indent_size: 2 });
7
+ const source = ts.createSourceFile("test.tsx", code, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
8
+ return ts.createPrinter({ removeComments: false }).printFile(source).trim();
8
9
  }
9
10
  stubEnvVars();
10
11
  beforeEach(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {
@@ -182,6 +182,7 @@
182
182
  "magic-string": "~0.30.21",
183
183
  "picocolors": "~1.1.1",
184
184
  "proper-lockfile": "~4.1.2",
185
+ "playwright-core": "1.60.0",
185
186
  "puppeteer-core": "~24.42.0",
186
187
  "react-is": "~19.2.6",
187
188
  "rsc-html-stream": "~0.0.7",
@@ -212,11 +213,9 @@
212
213
  "wrangler": "^4.85.0",
213
214
  "capnweb": "~0.5.0",
214
215
  "@types/debug": "~4.1.13",
215
- "@types/js-beautify": "~1.14.3",
216
216
  "@types/lodash": "~4.17.24",
217
217
  "@types/node": "~25.6.0",
218
218
  "@types/proper-lockfile": "~4.1.4",
219
- "js-beautify": "~1.15.4",
220
219
  "semver": "~7.7.4",
221
220
  "tsx": "~4.21.0",
222
221
  "typescript": "~6.0.3",