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.
- package/dist/lib/e2e/browser.d.mts +4 -0
- package/dist/lib/e2e/browser.mjs +58 -0
- package/dist/lib/e2e/constants.d.mts +2 -0
- package/dist/lib/e2e/constants.mjs +6 -0
- package/dist/lib/e2e/dev.mjs +7 -18
- package/dist/lib/e2e/release.d.mts +7 -0
- package/dist/lib/e2e/release.mjs +78 -2
- package/dist/lib/e2e/tarball.mjs +4 -1
- package/dist/lib/e2e/testHarness.d.mts +1 -7
- package/dist/lib/e2e/testHarness.mjs +30 -10
- package/dist/lib/smokeTests/browser.d.mts +1 -1
- package/dist/lib/smokeTests/browser.mjs +36 -30
- package/dist/lib/smokeTests/release.d.mts +8 -1
- package/dist/lib/smokeTests/release.mjs +54 -29
- package/dist/lib/smokeTests/runSmokeTests.mjs +1 -1
- package/dist/runtime/lib/router.d.ts +1 -0
- package/dist/runtime/lib/router.js +27 -2
- package/dist/runtime/lib/router.test.js +96 -0
- package/dist/scripts/worker-run.mjs +42 -8
- package/dist/use-synced-state/__tests__/SyncStateServer.test.mjs +14 -7
- package/dist/use-synced-state/__tests__/worker.test.mjs +41 -2
- package/dist/use-synced-state/worker.mjs +34 -3
- package/dist/vite/transformJsxScriptTagsPlugin.test.mjs +4 -3
- package/package.json +2 -3
|
@@ -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
|
*/
|
package/dist/lib/e2e/browser.mjs
CHANGED
|
@@ -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
|
package/dist/lib/e2e/dev.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
package/dist/lib/e2e/release.mjs
CHANGED
|
@@ -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
|
package/dist/lib/e2e/tarball.mjs
CHANGED
|
@@ -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(
|
|
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<
|
|
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
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
558
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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("
|
|
12
|
+
vi.mock("../../runtime/entries/router", () => ({
|
|
13
13
|
route: vi.fn((path, handler) => ({ path, handler })),
|
|
14
14
|
}));
|
|
15
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|