rwsdk 1.0.0-beta.15 → 1.0.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,5 +7,6 @@ export declare const VENDOR_CLIENT_BARREL_PATH: string;
7
7
  export declare const VENDOR_SERVER_BARREL_PATH: string;
8
8
  export declare const VENDOR_CLIENT_BARREL_EXPORT_PATH = "rwsdk/__vendor_client_barrel";
9
9
  export declare const VENDOR_SERVER_BARREL_EXPORT_PATH = "rwsdk/__vendor_server_barrel";
10
+ export declare const RW_STATE_EXPORT_PATH = "rwsdk/__state";
10
11
  export declare const INTERMEDIATE_SSR_BRIDGE_PATH: string;
11
12
  export declare const CLIENT_MANIFEST_RELATIVE_PATH: string;
@@ -9,5 +9,6 @@ export const VENDOR_CLIENT_BARREL_PATH = path.resolve(INTERMEDIATES_OUTPUT_DIR,
9
9
  export const VENDOR_SERVER_BARREL_PATH = path.resolve(INTERMEDIATES_OUTPUT_DIR, "rwsdk-vendor-server-barrel.js");
10
10
  export const VENDOR_CLIENT_BARREL_EXPORT_PATH = "rwsdk/__vendor_client_barrel";
11
11
  export const VENDOR_SERVER_BARREL_EXPORT_PATH = "rwsdk/__vendor_server_barrel";
12
+ export const RW_STATE_EXPORT_PATH = "rwsdk/__state";
12
13
  export const INTERMEDIATE_SSR_BRIDGE_PATH = resolve(INTERMEDIATES_OUTPUT_DIR, "ssr", "ssr_bridge.js");
13
14
  export const CLIENT_MANIFEST_RELATIVE_PATH = resolve("dist", "client", ".vite", "manifest.json");
@@ -1,4 +1,5 @@
1
- export declare const IS_DEBUG_MODE: string | boolean;
1
+ export declare const IS_CI: boolean;
2
+ export declare const IS_DEBUG_MODE: boolean;
2
3
  export declare const SETUP_PLAYGROUND_ENV_TIMEOUT: number;
3
4
  export declare const DEPLOYMENT_TIMEOUT: number;
4
5
  export declare const DEPLOYMENT_MIN_TRIES: number;
@@ -1,6 +1,13 @@
1
+ export const IS_CI = !!((process.env.CI && !process.env.NOT_CI) ||
2
+ process.env.GITHUB_ACTIONS ||
3
+ process.env.GITLAB_CI ||
4
+ process.env.CIRCLECI ||
5
+ process.env.TRAVIS ||
6
+ process.env.JENKINS_URL ||
7
+ process.env.NETLIFY);
1
8
  export const IS_DEBUG_MODE = process.env.RWSDK_E2E_DEBUG
2
- ? process.env.RWSDK_E2E_DEBUG
3
- : !process.env.CI;
9
+ ? process.env.RWSDK_E2E_DEBUG === "true"
10
+ : !IS_CI;
4
11
  export const SETUP_PLAYGROUND_ENV_TIMEOUT = process.env
5
12
  .RWSDK_SETUP_PLAYGROUND_ENV_TIMEOUT
6
13
  ? parseInt(process.env.RWSDK_SETUP_PLAYGROUND_ENV_TIMEOUT, 10)
@@ -109,7 +109,6 @@ export async function runDevServer(packageManager = "pnpm", cwd) {
109
109
  // Listen for all output to get the URL
110
110
  const handleOutput = (data, source) => {
111
111
  const output = data.toString();
112
- console.log(output);
113
112
  allOutput += output; // Accumulate all output
114
113
  log("Received output from %s: %s", source, output.replace(/\n/g, "\\n"));
115
114
  if (!url) {
@@ -1,3 +1,4 @@
1
+ import { createHash } from "crypto";
1
2
  import debug from "debug";
2
3
  import { copy, pathExists } from "fs-extra";
3
4
  import ignore from "ignore";
@@ -8,12 +9,25 @@ import { basename, join, relative, resolve } from "path";
8
9
  import tmp from "tmp-promise";
9
10
  import { $ } from "../../lib/$.mjs";
10
11
  import { ROOT_DIR } from "../constants.mjs";
11
- import { INSTALL_DEPENDENCIES_RETRIES } from "./constants.mjs";
12
+ import { INSTALL_DEPENDENCIES_RETRIES, IS_CI } from "./constants.mjs";
12
13
  import { retry } from "./retry.mjs";
13
14
  const log = debug("rwsdk:e2e:environment");
15
+ const IS_CACHE_ENABLED = process.env.RWSDK_E2E_CACHE
16
+ ? process.env.RWSDK_E2E_CACHE === "1"
17
+ : !IS_CI;
18
+ if (IS_CACHE_ENABLED) {
19
+ log("E2E test caching is enabled.");
20
+ }
14
21
  const getTempDir = async () => {
15
22
  return tmp.dir({ unsafeCleanup: true });
16
23
  };
24
+ function slugify(str) {
25
+ return str
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9-]/g, "-")
28
+ .replace(/--+/g, "-")
29
+ .replace(/^-|-$/g, "");
30
+ }
17
31
  const createSdkTarball = async () => {
18
32
  const existingTarballPath = process.env.RWSKD_SMOKE_TEST_TARBALL_PATH;
19
33
  if (existingTarballPath) {
@@ -28,15 +42,27 @@ const createSdkTarball = async () => {
28
42
  }, // No-op cleanup
29
43
  };
30
44
  }
31
- const packResult = await $({ cwd: ROOT_DIR, stdio: "pipe" }) `npm pack`;
32
- const tarballName = packResult.stdout?.trim();
33
- const tarballPath = path.join(ROOT_DIR, tarballName);
34
- log(`📦 Created tarball: ${tarballPath}`);
45
+ // Create a temporary directory to receive the tarball, ensuring a stable path.
46
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "rwsdk-tarball-"));
47
+ await $({
48
+ cwd: ROOT_DIR,
49
+ stdio: "pipe",
50
+ }) `npm pack --pack-destination=${tempDir}`;
51
+ // We need to determine the tarball's name, as it's version-dependent.
52
+ // Running `npm pack --dry-run` gives us the filename without creating a file.
53
+ const packDryRun = await $({
54
+ cwd: ROOT_DIR,
55
+ stdio: "pipe",
56
+ }) `npm pack --dry-run`;
57
+ const tarballName = packDryRun.stdout?.trim();
58
+ const tarballPath = path.join(tempDir, tarballName);
59
+ if (!fs.existsSync(tarballPath)) {
60
+ throw new Error(`Tarball was not created in the expected location: ${tarballPath}`);
61
+ }
62
+ log(`📦 Created tarball in stable temp location: ${tarballPath}`);
35
63
  const cleanupTarball = async () => {
36
- if (fs.existsSync(tarballPath)) {
37
- log(`🧹 Cleaning up tarball: ${tarballPath}`);
38
- await fs.promises.rm(tarballPath, { force: true });
39
- }
64
+ log(`🧹 Cleaning up tarball directory: ${tempDir}`);
65
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
40
66
  };
41
67
  return { tarballPath, cleanupTarball };
42
68
  };
@@ -59,7 +85,7 @@ export async function copyProjectToTempDir(projectDir, resourceUniqueKey, packag
59
85
  const sourceDir = monorepoRoot || projectDir;
60
86
  // Create unique project directory name
61
87
  const originalDirName = basename(sourceDir);
62
- const workerName = `${originalDirName}-test-${resourceUniqueKey}`;
88
+ const workerName = `${slugify(originalDirName)}-test-${resourceUniqueKey}`;
63
89
  const tempCopyRoot = resolve(tempDir.path, workerName);
64
90
  // If it's a monorepo, the targetDir for commands is a subdirectory
65
91
  const targetDir = monorepoRoot
@@ -115,7 +141,9 @@ export async function copyProjectToTempDir(projectDir, resourceUniqueKey, packag
115
141
  const workspaces = rwsdkWs.workspaces;
116
142
  if (packageManager === "pnpm") {
117
143
  const pnpmWsPath = join(tempCopyRoot, "pnpm-workspace.yaml");
118
- const pnpmWsConfig = `packages:\n${workspaces.map((w) => ` - '${w}'`).join("\n")}\n`;
144
+ const pnpmWsConfig = `packages:\n${workspaces
145
+ .map((w) => ` - '${w}'`)
146
+ .join("\n")}\n`;
119
147
  await fs.promises.writeFile(pnpmWsPath, pnpmWsConfig);
120
148
  log("Created pnpm-workspace.yaml");
121
149
  }
@@ -157,7 +185,7 @@ export async function copyProjectToTempDir(projectDir, resourceUniqueKey, packag
157
185
  await setTarballDependency(targetDir, tarballFilename);
158
186
  // Install dependencies in the target directory
159
187
  const installDir = monorepoRoot ? tempCopyRoot : targetDir;
160
- await retry(() => installDependencies(installDir, packageManager), {
188
+ await retry(() => installDependencies(installDir, packageManager, projectDir, monorepoRoot), {
161
189
  retries: INSTALL_DEPENDENCIES_RETRIES,
162
190
  delay: 1000,
163
191
  });
@@ -168,7 +196,42 @@ export async function copyProjectToTempDir(projectDir, resourceUniqueKey, packag
168
196
  await cleanupTarball();
169
197
  }
170
198
  }
171
- async function installDependencies(targetDir, packageManager = "pnpm") {
199
+ async function installDependencies(targetDir, packageManager = "pnpm", projectDir, monorepoRoot) {
200
+ if (IS_CACHE_ENABLED) {
201
+ // Generate a checksum of the SDK's dist directory to factor into the cache key
202
+ const { stdout: sdkDistChecksum } = await $("find . -type f | sort | md5sum", {
203
+ shell: true,
204
+ cwd: path.join(ROOT_DIR, "dist"),
205
+ });
206
+ const projectIdentifier = monorepoRoot
207
+ ? `${monorepoRoot}-${projectDir}`
208
+ : projectDir;
209
+ const projectHash = createHash("md5")
210
+ .update(`${projectIdentifier}-${sdkDistChecksum}`)
211
+ .digest("hex")
212
+ .substring(0, 8);
213
+ const cacheDirName = monorepoRoot
214
+ ? basename(monorepoRoot)
215
+ : basename(projectDir);
216
+ const cacheRoot = path.join(os.tmpdir(), "rwsdk-e2e-cache", `${cacheDirName}-${projectHash}`);
217
+ const nodeModulesCachePath = path.join(cacheRoot, "node_modules");
218
+ if (await pathExists(nodeModulesCachePath)) {
219
+ console.log(`✅ CACHE HIT for source "${projectIdentifier}": Found cached node_modules. Hard-linking from ${nodeModulesCachePath}`);
220
+ try {
221
+ // Use cp -al for a fast, hardlink-based copy
222
+ await $("cp", ["-al", nodeModulesCachePath, join(targetDir, "node_modules")], { stdio: "pipe" });
223
+ console.log(`✅ Hardlink copy created successfully.`);
224
+ }
225
+ catch (e) {
226
+ console.warn(`⚠️ Hardlink copy failed, falling back to full copy. Error: ${e.message}`);
227
+ // Fallback to a regular copy if hardlinking fails (e.g., cross-device)
228
+ await copy(nodeModulesCachePath, join(targetDir, "node_modules"));
229
+ console.log(`✅ Full copy created successfully.`);
230
+ }
231
+ return;
232
+ }
233
+ console.log(`ℹ️ CACHE MISS for source "${projectIdentifier}": No cached node_modules found at ${nodeModulesCachePath}. Proceeding with installation.`);
234
+ }
172
235
  console.log(`📦 Installing project dependencies in ${targetDir} using ${packageManager}...`);
173
236
  try {
174
237
  // Clean up any pre-existing node_modules and lockfiles
@@ -220,6 +283,33 @@ async function installDependencies(targetDir, packageManager = "pnpm") {
220
283
  },
221
284
  });
222
285
  console.log("✅ Dependencies installed successfully");
286
+ // After successful install, populate the cache if enabled
287
+ if (IS_CACHE_ENABLED) {
288
+ // Re-calculate cache path to be safe
289
+ const { stdout: sdkDistChecksum } = await $("find . -type f | sort | md5sum", {
290
+ shell: true,
291
+ cwd: path.join(ROOT_DIR, "dist"),
292
+ });
293
+ const projectIdentifier = monorepoRoot
294
+ ? `${monorepoRoot}-${projectDir}`
295
+ : projectDir;
296
+ const projectHash = createHash("md5")
297
+ .update(`${projectIdentifier}-${sdkDistChecksum}`)
298
+ .digest("hex")
299
+ .substring(0, 8);
300
+ const cacheDirName = monorepoRoot
301
+ ? basename(monorepoRoot)
302
+ : basename(projectDir);
303
+ const cacheRoot = path.join(os.tmpdir(), "rwsdk-e2e-cache", `${cacheDirName}-${projectHash}`);
304
+ const nodeModulesCachePath = path.join(cacheRoot, "node_modules");
305
+ console.log(`Caching node_modules to ${nodeModulesCachePath} for future runs...`);
306
+ // Ensure parent directory exists
307
+ await fs.promises.mkdir(path.dirname(nodeModulesCachePath), {
308
+ recursive: true,
309
+ });
310
+ await copy(join(targetDir, "node_modules"), nodeModulesCachePath);
311
+ console.log(`✅ node_modules cached successfully.`);
312
+ }
223
313
  // Log installation details at debug level
224
314
  if (result.stdout) {
225
315
  log(`${packageManager} install output: %s`, result.stdout);
@@ -4,5 +4,5 @@ export interface PollOptions {
4
4
  minTries: number;
5
5
  onRetry?: (error: unknown, tries: number) => void;
6
6
  }
7
- export declare function poll(fn: () => Promise<boolean>, options?: Partial<PollOptions>): Promise<void>;
7
+ export declare function poll(fn: () => boolean | Promise<boolean>, options?: Partial<PollOptions>): Promise<void>;
8
8
  export declare function pollValue<T>(fn: () => Promise<T>, options?: Partial<PollOptions>): Promise<T>;
@@ -37,6 +37,11 @@ export interface SetupPlaygroundEnvironmentOptions {
37
37
  * @default true
38
38
  */
39
39
  deploy?: boolean;
40
+ /**
41
+ * Whether to automatically start the dev server.
42
+ * @default true
43
+ */
44
+ autoStartDevServer?: boolean;
40
45
  }
41
46
  /**
42
47
  * A Vitest hook that sets up a playground environment for a test file.
@@ -77,8 +82,6 @@ export declare function createDeployment(): {
77
82
  */
78
83
  export declare function runTestWithRetries(name: string, attemptFn: () => Promise<void>): Promise<void>;
79
84
  type SDKRunner = (name: string, testLogic: (context: {
80
- createDevServer: () => Promise<DevServerInstance>;
81
- createDeployment: () => Promise<DeploymentInstance>;
82
85
  browser: Browser;
83
86
  page: Page;
84
87
  projectDir: string;
@@ -85,7 +85,9 @@ function getPlaygroundDirFromImportMeta(importMetaUrl) {
85
85
  * This ensures that tests run in a clean, isolated environment.
86
86
  */
87
87
  export function setupPlaygroundEnvironment(options) {
88
- const { sourceProjectDir, monorepoRoot, dev = true, deploy = true, } = typeof options === "string" ? { sourceProjectDir: options } : options;
88
+ const { sourceProjectDir, monorepoRoot, dev = true, deploy = true, autoStartDevServer = true, } = typeof options === "string"
89
+ ? { sourceProjectDir: options, autoStartDevServer: true }
90
+ : options;
89
91
  ensureHooksRegistered();
90
92
  beforeAll(async () => {
91
93
  let projectDir;
@@ -111,14 +113,16 @@ export function setupPlaygroundEnvironment(options) {
111
113
  projectDir: devEnv.targetDir,
112
114
  cleanup: devEnv.cleanup,
113
115
  };
114
- const devControl = createDevServer();
115
- globalDevInstancePromise = devControl.start().then((instance) => {
116
- globalDevInstance = instance;
117
- return instance;
118
- });
119
- // Prevent unhandled promise rejections. The error will be handled inside
120
- // the test's beforeEach hook where this promise is awaited.
121
- globalDevInstancePromise.catch(() => { });
116
+ if (autoStartDevServer) {
117
+ const devControl = createDevServer();
118
+ globalDevInstancePromise = devControl.start().then((instance) => {
119
+ globalDevInstance = instance;
120
+ return instance;
121
+ });
122
+ // Prevent unhandled promise rejections. The error will be handled inside
123
+ // the test's beforeEach hook where this promise is awaited.
124
+ globalDevInstancePromise.catch(() => { });
125
+ }
122
126
  }
123
127
  else {
124
128
  globalDevPlaygroundEnv = null;
@@ -1,6 +1,7 @@
1
1
  import { AsyncLocalStorage } from "async_hooks";
2
- const requestInfoDeferred = Promise.withResolvers();
3
- const requestInfoStore = new AsyncLocalStorage();
2
+ import { defineRwState } from "rwsdk/__state";
3
+ const requestInfoDeferred = defineRwState("requestInfoDeferred", () => Promise.withResolvers());
4
+ const requestInfoStore = defineRwState("requestInfoStore", () => new AsyncLocalStorage());
4
5
  const requestInfoBase = {};
5
6
  const REQUEST_INFO_KEYS = ["request", "params", "ctx", "rw", "cf", "response"];
6
7
  REQUEST_INFO_KEYS.forEach((key) => {
@@ -0,0 +1,3 @@
1
+ export declare function defineRwState<T>(key: string, initializer: () => T): T;
2
+ export declare function getRwState<T>(key: string): T | undefined;
3
+ export declare function setRwState<T>(key: string, value: T): void;
@@ -0,0 +1,13 @@
1
+ const state = {};
2
+ export function defineRwState(key, initializer) {
3
+ if (!(key in state)) {
4
+ state[key] = initializer();
5
+ }
6
+ return state[key];
7
+ }
8
+ export function getRwState(key) {
9
+ return state[key];
10
+ }
11
+ export function setRwState(key, value) {
12
+ state[key] = value;
13
+ }
@@ -99,28 +99,20 @@ const findUp = async (names, startDir) => {
99
99
  return undefined;
100
100
  };
101
101
  const getMonorepoRoot = async (startDir) => {
102
- try {
103
- // `pnpm root` is the most reliable way to find the workspace root node_modules
104
- const { stdout } = await $({
105
- cwd: startDir,
106
- }) `pnpm root`;
107
- // pnpm root returns the node_modules path, so we go up one level
108
- return path.resolve(stdout, "..");
109
- }
110
- catch (e) {
111
- console.warn(`Could not determine pnpm root from ${startDir}. Falling back to file search.`);
112
- const root = await findUp(["pnpm-workspace.yaml"], startDir);
113
- if (root) {
114
- return root;
115
- }
116
- }
117
- console.warn("Could not find pnpm monorepo root. Using parent directory of target as fallback.");
118
- return path.resolve(startDir, "..");
102
+ return await findUp(["pnpm-workspace.yaml"], startDir);
119
103
  };
120
104
  const areDependenciesEqual = (deps1, deps2) => {
121
105
  // Simple string comparison for this use case is sufficient
122
106
  return JSON.stringify(deps1 ?? {}) === JSON.stringify(deps2 ?? {});
123
107
  };
108
+ const isPlaygroundExample = async (targetDir, monorepoRoot) => {
109
+ const pkgJson = JSON.parse(await fs.readFile(path.join(monorepoRoot, "package.json"), "utf-8"));
110
+ if (pkgJson.name === "rw-sdk-monorepo") {
111
+ const playgroundDir = path.join(monorepoRoot, "playground");
112
+ return targetDir.startsWith(playgroundDir);
113
+ }
114
+ return false;
115
+ };
124
116
  const performFullSync = async (sdkDir, targetDir, monorepoRoot) => {
125
117
  console.log("📦 Performing full sync with tarball...");
126
118
  let tarballPath = "";
@@ -178,8 +170,14 @@ const performSync = async (sdkDir, targetDir) => {
178
170
  // Clean up vite cache in the target project
179
171
  await cleanupViteEntries(targetDir);
180
172
  const monorepoRoot = await getMonorepoRoot(targetDir);
173
+ const rootDir = monorepoRoot ?? targetDir;
181
174
  const projectName = path.basename(targetDir);
182
- const installedSdkPackageJsonPath = path.join(monorepoRoot, "node_modules", `.rwsync_${projectName}`, "node_modules", "rwsdk", "package.json");
175
+ if (monorepoRoot && (await isPlaygroundExample(targetDir, monorepoRoot))) {
176
+ console.log("Playground example detected. Skipping file sync; workspace linking will be used.");
177
+ console.log("✅ Done syncing");
178
+ return;
179
+ }
180
+ const installedSdkPackageJsonPath = path.join(rootDir, "node_modules", `.rwsync_${projectName}`, "node_modules", "rwsdk", "package.json");
183
181
  let needsFullSync = false;
184
182
  if (!existsSync(installedSdkPackageJsonPath)) {
185
183
  console.log("No previous sync found, performing full sync.");
@@ -195,10 +193,10 @@ const performSync = async (sdkDir, targetDir) => {
195
193
  }
196
194
  }
197
195
  if (needsFullSync) {
198
- await performFullSync(sdkDir, targetDir, monorepoRoot);
196
+ await performFullSync(sdkDir, targetDir, rootDir);
199
197
  }
200
198
  else {
201
- await performFastSync(sdkDir, targetDir, monorepoRoot);
199
+ await performFastSync(sdkDir, targetDir, rootDir);
202
200
  }
203
201
  console.log("✅ Done syncing");
204
202
  };
@@ -0,0 +1,11 @@
1
+ import enhancedResolve from "enhanced-resolve";
2
+ export declare const ENV_RESOLVERS: {
3
+ ssr: enhancedResolve.ResolveFunction;
4
+ worker: enhancedResolve.ResolveFunction;
5
+ client: enhancedResolve.ResolveFunction;
6
+ };
7
+ export declare const maybeResolveEnvImport: ({ id, envName, projectRootDir, }: {
8
+ id: string;
9
+ envName: keyof typeof ENV_RESOLVERS;
10
+ projectRootDir: string;
11
+ }) => string | undefined;
@@ -0,0 +1,20 @@
1
+ import enhancedResolve from "enhanced-resolve";
2
+ export const ENV_RESOLVERS = {
3
+ ssr: enhancedResolve.create.sync({
4
+ conditionNames: ["workerd", "worker", "edge", "default"],
5
+ }),
6
+ worker: enhancedResolve.create.sync({
7
+ conditionNames: ["react-server", "workerd", "worker", "edge", "default"],
8
+ }),
9
+ client: enhancedResolve.create.sync({
10
+ conditionNames: ["browser", "default"],
11
+ }),
12
+ };
13
+ export const maybeResolveEnvImport = ({ id, envName, projectRootDir, }) => {
14
+ try {
15
+ return ENV_RESOLVERS[envName](projectRootDir, id) || undefined;
16
+ }
17
+ catch (error) {
18
+ return undefined;
19
+ }
20
+ };
@@ -0,0 +1,2 @@
1
+ import { type Plugin } from "vite";
2
+ export declare function hmrStabilityPlugin(): Plugin;
@@ -0,0 +1,68 @@
1
+ import debug from "debug";
2
+ const log = debug("rws-vite-plugin:hmr-stability");
3
+ let stabilityPromise = null;
4
+ let stabilityResolver = null;
5
+ let debounceTimer = null;
6
+ const DEBOUNCE_MS = 500;
7
+ function startWaitingForStability() {
8
+ if (!stabilityPromise) {
9
+ log("Starting to wait for server stability...");
10
+ stabilityPromise = new Promise((resolve) => {
11
+ stabilityResolver = resolve;
12
+ });
13
+ // Start the timer. If it fires, we're stable.
14
+ debounceTimer = setTimeout(finishWaiting, DEBOUNCE_MS);
15
+ }
16
+ }
17
+ function activityDetected() {
18
+ if (stabilityPromise) {
19
+ // If we're waiting for stability, reset the timer.
20
+ log("Activity detected, resetting stability timer.");
21
+ if (debounceTimer)
22
+ clearTimeout(debounceTimer);
23
+ debounceTimer = setTimeout(finishWaiting, DEBOUNCE_MS);
24
+ }
25
+ }
26
+ function finishWaiting() {
27
+ if (stabilityResolver) {
28
+ log("Server appears stable. Resolving promise.");
29
+ stabilityResolver();
30
+ }
31
+ stabilityPromise = null;
32
+ stabilityResolver = null;
33
+ debounceTimer = null;
34
+ }
35
+ export function hmrStabilityPlugin() {
36
+ return {
37
+ name: "rws-vite-plugin:hmr-stability",
38
+ // Monitor server activity
39
+ transform() {
40
+ activityDetected();
41
+ return null;
42
+ },
43
+ configureServer(server) {
44
+ // Return a function to ensure our middleware is placed after internal middlewares
45
+ return () => {
46
+ server.middlewares.use(async function rwsdkStaleBundleErrorHandler(err, req, res, next) {
47
+ if (err &&
48
+ typeof err.message === "string" &&
49
+ err.message.includes("new version of the pre-bundle")) {
50
+ log("Caught stale pre-bundle error. Waiting for server to stabilize...");
51
+ startWaitingForStability();
52
+ await stabilityPromise;
53
+ log("Server stabilized. Sending full-reload and redirecting.");
54
+ // Signal the client to do a full page reload.
55
+ server.environments.client.hot.send({
56
+ type: "full-reload",
57
+ });
58
+ // No need to wait further here, the stability promise handled it.
59
+ res.writeHead(307, { Location: req.url });
60
+ res.end();
61
+ return;
62
+ }
63
+ next(err);
64
+ });
65
+ };
66
+ },
67
+ };
68
+ }
@@ -1,15 +1,9 @@
1
- import enhancedResolve from "enhanced-resolve";
2
1
  import { Plugin } from "vite";
3
2
  export declare const ENV_PREDEFINED_IMPORTS: {
4
3
  worker: string[];
5
4
  ssr: string[];
6
5
  client: string[];
7
6
  };
8
- export declare const ENV_RESOLVERS: {
9
- ssr: enhancedResolve.ResolveFunction;
10
- worker: enhancedResolve.ResolveFunction;
11
- client: enhancedResolve.ResolveFunction;
12
- };
13
7
  export declare const knownDepsResolverPlugin: ({ projectRootDir, }: {
14
8
  projectRootDir: string;
15
9
  }) => Plugin[];
@@ -1,7 +1,7 @@
1
1
  import debug from "debug";
2
- import enhancedResolve from "enhanced-resolve";
3
2
  import { ROOT_DIR } from "../lib/constants.mjs";
4
3
  import { ensureAliasArray } from "./ensureAliasArray.mjs";
4
+ import { ENV_RESOLVERS } from "./envResolvers.mjs";
5
5
  const log = debug("rwsdk:vite:known-deps-resolver-plugin");
6
6
  const KNOWN_PREFIXES = [
7
7
  "react",
@@ -36,17 +36,6 @@ export const ENV_PREDEFINED_IMPORTS = {
36
36
  "react-server-dom-webpack/client.edge",
37
37
  ],
38
38
  };
39
- export const ENV_RESOLVERS = {
40
- ssr: enhancedResolve.create.sync({
41
- conditionNames: ["workerd", "worker", "edge", "default"],
42
- }),
43
- worker: enhancedResolve.create.sync({
44
- conditionNames: ["react-server", "workerd", "worker", "edge", "default"],
45
- }),
46
- client: enhancedResolve.create.sync({
47
- conditionNames: ["browser", "default"],
48
- }),
49
- };
50
39
  function resolveKnownImport(id, envName, projectRootDir, isPrefixedImport = false) {
51
40
  if (!isPrefixedImport) {
52
41
  const isKnownImport = KNOWN_PREFIXES.some((prefix) => id === prefix || id.startsWith(`${prefix}/`));
@@ -23,6 +23,8 @@ import { moveStaticAssetsPlugin } from "./moveStaticAssetsPlugin.mjs";
23
23
  import { prismaPlugin } from "./prismaPlugin.mjs";
24
24
  import { resolveForcedPaths } from "./resolveForcedPaths.mjs";
25
25
  import { ssrBridgePlugin } from "./ssrBridgePlugin.mjs";
26
+ import { staleDepRetryPlugin } from "./staleDepRetryPlugin.mjs";
27
+ import { statePlugin } from "./statePlugin.mjs";
26
28
  import { transformJsxScriptTagsPlugin } from "./transformJsxScriptTagsPlugin.mjs";
27
29
  import { useClientLookupPlugin } from "./useClientLookupPlugin.mjs";
28
30
  import { useServerLookupPlugin } from "./useServerLookupPlugin.mjs";
@@ -77,13 +79,13 @@ export const redwoodPlugin = async (options = {}) => {
77
79
  !(await pathExists(resolve(projectRootDir, ".wrangler"))) &&
78
80
  (await hasPkgScript(projectRootDir, "dev:init"))) {
79
81
  console.log("🚀 Project has no .wrangler directory yet, assuming fresh install: running `npm run dev:init`...");
80
- await $({
81
- // context(justinvdm, 01 Apr 2025): We want to avoid interactive migration y/n prompt, so we ignore stdin
82
- // as a signal to operate in no-tty mode
83
- stdio: ["ignore", "inherit", "inherit"],
84
- }) `npm run dev:init`;
82
+ // @ts-ignore
83
+ $.verbose = true;
84
+ await $ `npm run dev:init`;
85
85
  }
86
86
  return [
87
+ staleDepRetryPlugin(),
88
+ statePlugin({ projectRootDir }),
87
89
  devServerTimingPlugin(),
88
90
  devServerConstantPlugin(),
89
91
  directiveModulesDevPlugin({
@@ -10,9 +10,32 @@ export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
10
10
  const ssrBridgePlugin = {
11
11
  name: "rwsdk:ssr-bridge",
12
12
  enforce: "pre",
13
- async configureServer(server) {
13
+ configureServer(server) {
14
14
  devServer = server;
15
+ const ssrHot = server.environments.ssr.hot;
16
+ const originalSsrHotSend = ssrHot.send;
17
+ // Chain the SSR's full reload behaviour to the worker
18
+ ssrHot.send = (...args) => {
19
+ if (typeof args[0] === "object" && args[0].type === "full-reload") {
20
+ for (const envName of ["worker", "ssr"]) {
21
+ const moduleGraph = server.environments[envName].moduleGraph;
22
+ moduleGraph.invalidateAll();
23
+ }
24
+ log("SSR full-reload detected, propagating to worker");
25
+ // context(justinvdm, 21 Oct 2025): By sending the full-reload event
26
+ // to the worker, we ensure that the worker's module runner cache is
27
+ // invalidated, as it would have been if this were a full-reload event
28
+ // from the worker.
29
+ server.environments.worker.hot.send.apply(server.environments.worker.hot, args);
30
+ }
31
+ return originalSsrHotSend.apply(ssrHot, args);
32
+ };
15
33
  log("Configured dev server");
34
+ const originalRun = devServer.environments.ssr.depsOptimizer?.run;
35
+ devServer.environments.ssr.depsOptimizer.run = async () => {
36
+ originalRun();
37
+ devServer.environments.worker.depsOptimizer.run();
38
+ };
16
39
  },
17
40
  config(_, { command, isPreview }) {
18
41
  isDev = !isPreview && command === "serve";
@@ -48,7 +71,7 @@ export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
48
71
  log("Worker environment esbuild configuration complete");
49
72
  }
50
73
  },
51
- async resolveId(id) {
74
+ async resolveId(id, importer) {
52
75
  // Skip during directive scanning to avoid performance issues
53
76
  if (process.env.RWSDK_DIRECTIVE_SCAN_ACTIVE) {
54
77
  return;
@@ -107,46 +130,93 @@ export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
107
130
  if (id.startsWith(VIRTUAL_SSR_PREFIX) &&
108
131
  this.environment.name === "worker") {
109
132
  const realId = id.slice(VIRTUAL_SSR_PREFIX.length);
110
- const idForFetch = realId.endsWith(".css.js")
133
+ let idForFetch = realId.endsWith(".css.js")
111
134
  ? realId.slice(0, -3)
112
135
  : realId;
113
136
  log("Virtual SSR module load: id=%s, realId=%s, idForFetch=%s", id, realId, idForFetch);
114
137
  if (isDev) {
115
- log("Dev mode: fetching SSR module for realPath=%s", idForFetch);
116
- const result = await devServer?.environments.ssr.fetchModule(idForFetch);
117
- process.env.VERBOSE &&
118
- log("Fetch module result: id=%s, result=%O", idForFetch, result);
119
- const code = "code" in result ? result.code : undefined;
120
- if (idForFetch.endsWith(".css") &&
121
- !idForFetch.endsWith(".module.css")) {
122
- process.env.VERBOSE &&
123
- log("Plain CSS file, returning empty module for %s", idForFetch);
124
- return "export default {};";
138
+ // from the SSR environment, which is crucial for things like server
139
+ // components.
140
+ try {
141
+ const ssrOptimizer = devServer.environments.ssr.depsOptimizer;
142
+ // context(justinvdm, 20 Oct 2025): This is the fix for the stale
143
+ // dependency issue. The root cause is the "unhashed-to-hashed"
144
+ // transition. Our worker code imports a clean ID
145
+ // (`rwsdk/__ssr_bridge`), but we expect to fetch the hashed,
146
+ // optimized version from the SSR environment. When a re-optimization
147
+ // happens, Vite's `fetchModule` (running in the SSR env) finds a
148
+ // "ghost node" in its module graph for the clean ID and incorrectly
149
+ // re-uses its stale, hashed `id` property.
150
+ //
151
+ // To fix this, we manually resolve the hashed path here, before
152
+ // asking the SSR env to process the module. We look into the SSR
153
+ // optimizer's metadata to find the correct, up-to-date hash and
154
+ // construct the path ourselves. This ensures the SSR env is
155
+ // always working with the correct, versioned ID, bypassing the
156
+ // faulty ghost node lookup.
157
+ if (ssrOptimizer &&
158
+ Object.prototype.hasOwnProperty.call(ssrOptimizer.metadata.optimized, realId)) {
159
+ const depInfo = ssrOptimizer.metadata.optimized[realId];
160
+ idForFetch = ssrOptimizer.getOptimizedDepId(depInfo);
161
+ log("Manually resolved %s to hashed path for fetchModule: %s", realId, idForFetch);
162
+ }
163
+ log("Virtual SSR module load: id=%s, realId=%s, idForFetch=%s", id, realId, idForFetch);
164
+ log("Dev mode: fetching SSR module for realPath=%s", idForFetch);
165
+ // We use `fetchModule` with `cached: false` as a safeguard. Since
166
+ // we're in a `load` hook, we know the worker-side cache for this
167
+ // virtual module is stale. `cached: false` ensures that we also
168
+ // bypass any potentially stale transform result in the SSR
169
+ // environment's cache, guaranteeing we get the freshest possible
170
+ // code.
171
+ const result = await devServer.environments.ssr.fetchModule(idForFetch, undefined, { cached: false });
172
+ if ("code" in result) {
173
+ log("Fetched SSR module code length: %d", result.code?.length || 0);
174
+ const code = result.code;
175
+ if (idForFetch.endsWith(".css") &&
176
+ !idForFetch.endsWith(".module.css")) {
177
+ process.env.VERBOSE &&
178
+ log("Plain CSS file, returning empty module for %s", idForFetch);
179
+ return "export default {};";
180
+ }
181
+ const s = new MagicString(code || "");
182
+ const callsites = findSsrImportCallSites(idForFetch, code || "", log);
183
+ for (const site of callsites) {
184
+ const normalized = site.specifier.startsWith("/@id/")
185
+ ? site.specifier.slice("/@id/".length)
186
+ : site.specifier;
187
+ // context(justinvdm, 11 Aug 2025):
188
+ // - We replace __vite_ssr_import__ and __vite_ssr_dynamic_import__
189
+ // with import() calls so that the module graph can be built
190
+ // correctly (vite looks for imports and import()s to build module
191
+ // graph)
192
+ // - We prepend /@id/$VIRTUAL_SSR_PREFIX to the specifier so that we
193
+ // can stay within the SSR subgraph of the worker module graph
194
+ const replacement = `import("/@id/${VIRTUAL_SSR_PREFIX}${normalized}")`;
195
+ s.overwrite(site.start, site.end, replacement);
196
+ }
197
+ const out = s.toString();
198
+ process.env.VERBOSE &&
199
+ log("Transformed SSR module code for realId=%s: %s", realId, out);
200
+ return {
201
+ code: out,
202
+ map: null, // Sourcemaps are handled by fetchModule's inlining
203
+ };
204
+ }
205
+ else {
206
+ // This case can be hit if the module is already cached. We may
207
+ // need to handle this more gracefully, but for now we'll just
208
+ // return an empty module.
209
+ log("SSR module %s was already cached. Returning empty.", idForFetch);
210
+ return "export default {}";
211
+ }
125
212
  }
126
- log("Fetched SSR module code length: %d", code?.length || 0);
127
- const s = new MagicString(code || "");
128
- const callsites = findSsrImportCallSites(idForFetch, code || "", log);
129
- for (const site of callsites) {
130
- const normalized = site.specifier.startsWith("/@id/")
131
- ? site.specifier.slice("/@id/".length)
132
- : site.specifier;
133
- // context(justinvdm, 11 Aug 2025):
134
- // - We replace __vite_ssr_import__ and __vite_ssr_dynamic_import__
135
- // with import() calls so that the module graph can be built
136
- // correctly (vite looks for imports and import()s to build module
137
- // graph)
138
- // - We prepend /@id/$VIRTUAL_SSR_PREFIX to the specifier so that we
139
- // can stay within the SSR subgraph of the worker module graph
140
- const replacement = `import("/@id/${VIRTUAL_SSR_PREFIX}${normalized}")`;
141
- s.overwrite(site.start, site.end, replacement);
213
+ catch (e) {
214
+ log("Error fetching SSR module for realPath=%s: %s", id, e);
215
+ throw e;
142
216
  }
143
- const out = s.toString();
144
- log("Transformed SSR module code length: %d", out.length);
145
- process.env.VERBOSE &&
146
- log("Transformed SSR module code for realId=%s: %s", realId, out);
147
- return out;
148
217
  }
149
218
  }
219
+ return;
150
220
  },
151
221
  };
152
222
  return ssrBridgePlugin;
@@ -0,0 +1,2 @@
1
+ import { type Plugin } from "vite";
2
+ export declare function staleDepRetryPlugin(): Plugin;
@@ -0,0 +1,69 @@
1
+ import debug from "debug";
2
+ const log = debug("rwsdk:vite:stale-dep-retry-plugin");
3
+ let stabilityPromise = null;
4
+ let stabilityResolver = null;
5
+ let debounceTimer = null;
6
+ const DEBOUNCE_MS = 500;
7
+ function startWaitingForStability() {
8
+ if (!stabilityPromise) {
9
+ log("Starting to wait for server stability...");
10
+ stabilityPromise = new Promise((resolve) => {
11
+ stabilityResolver = resolve;
12
+ });
13
+ // Start the timer. If it fires, we're stable.
14
+ debounceTimer = setTimeout(finishWaiting, DEBOUNCE_MS);
15
+ }
16
+ }
17
+ function activityDetected() {
18
+ if (stabilityPromise) {
19
+ // If we're waiting for stability, reset the timer.
20
+ log("Activity detected, resetting stability timer.");
21
+ if (debounceTimer)
22
+ clearTimeout(debounceTimer);
23
+ debounceTimer = setTimeout(finishWaiting, DEBOUNCE_MS);
24
+ }
25
+ }
26
+ function finishWaiting() {
27
+ if (stabilityResolver) {
28
+ log("Server appears stable. Resolving promise.");
29
+ stabilityResolver();
30
+ }
31
+ stabilityPromise = null;
32
+ stabilityResolver = null;
33
+ debounceTimer = null;
34
+ }
35
+ export function staleDepRetryPlugin() {
36
+ return {
37
+ name: "rws-vite-plugin:stale-dep-retry",
38
+ apply: "serve",
39
+ // Monitor server activity by tapping into the transform hook. This is a
40
+ // reliable indicator that Vite is busy processing modules.
41
+ transform() {
42
+ activityDetected();
43
+ return null;
44
+ },
45
+ configureServer(server) {
46
+ // Return a function to ensure our middleware is placed after internal middlewares
47
+ return () => {
48
+ server.middlewares.use(async function rwsdkStaleBundleErrorHandler(err, req, res, next) {
49
+ if (err &&
50
+ typeof err.message === "string" &&
51
+ err.message.includes("new version of the pre-bundle")) {
52
+ log("Caught stale pre-bundle error. Waiting for server to stabilize...");
53
+ startWaitingForStability();
54
+ await stabilityPromise;
55
+ log("Server stabilized. Sending full-reload and redirecting.");
56
+ // Signal the client to do a full page reload.
57
+ server.environments.client.hot.send({
58
+ type: "full-reload",
59
+ });
60
+ res.writeHead(307, { Location: req.originalUrl || req.url });
61
+ res.end();
62
+ return;
63
+ }
64
+ next(err);
65
+ });
66
+ };
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from "vite";
2
+ export declare const statePlugin: ({ projectRootDir, }: {
3
+ projectRootDir: string;
4
+ }) => Plugin;
@@ -0,0 +1,62 @@
1
+ import debug from "debug";
2
+ import fs from "node:fs/promises";
3
+ import { RW_STATE_EXPORT_PATH } from "../lib/constants.mjs";
4
+ import { maybeResolveEnvImport } from "./envResolvers.mjs";
5
+ const log = debug("rwsdk:vite:state-plugin");
6
+ const VIRTUAL_STATE_PREFIX = "virtual:rwsdk:state:";
7
+ export const statePlugin = ({ projectRootDir, }) => {
8
+ let isDev = false;
9
+ const stateModulePath = maybeResolveEnvImport({
10
+ id: RW_STATE_EXPORT_PATH,
11
+ envName: "worker",
12
+ projectRootDir,
13
+ });
14
+ if (!stateModulePath) {
15
+ throw new Error(`[rwsdk] State module path not found for project root: ${projectRootDir}. This is likely a bug in RedwoodSDK. Please report this issue at https://github.com/redwoodjs/sdk/issues/new`);
16
+ }
17
+ return {
18
+ name: "rwsdk:state",
19
+ enforce: "pre",
20
+ config(_, { command, isPreview }) {
21
+ isDev = !isPreview && command === "serve";
22
+ },
23
+ configEnvironment(env, config) {
24
+ if (env === "worker") {
25
+ config.optimizeDeps ??= {};
26
+ config.optimizeDeps.esbuildOptions ??= {};
27
+ config.optimizeDeps.esbuildOptions.plugins ??= [];
28
+ config.optimizeDeps.esbuildOptions.plugins.push({
29
+ name: "rwsdk-state-external",
30
+ setup(build) {
31
+ build.onResolve({
32
+ // context(justinvdm, 13 Oct 2025): Vite dep optimizer slugifies the export path
33
+ filter: new RegExp(`^(${RW_STATE_EXPORT_PATH}|${VIRTUAL_STATE_PREFIX}.*)$`),
34
+ }, (args) => {
35
+ log("Marking as external: %s", args.path);
36
+ return {
37
+ path: args.path,
38
+ external: true,
39
+ };
40
+ });
41
+ },
42
+ });
43
+ }
44
+ },
45
+ resolveId(id) {
46
+ if (id === RW_STATE_EXPORT_PATH) {
47
+ if (isDev && this.environment.name === "worker") {
48
+ return `${VIRTUAL_STATE_PREFIX}${id}`;
49
+ }
50
+ else {
51
+ return stateModulePath;
52
+ }
53
+ }
54
+ },
55
+ async load(id) {
56
+ if (id.startsWith(VIRTUAL_STATE_PREFIX)) {
57
+ log("Loading virtual state module from %s", stateModulePath);
58
+ return await fs.readFile(stateModulePath, "utf-8");
59
+ }
60
+ },
61
+ };
62
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.0.0-beta.15",
3
+ "version": "1.0.0-beta.16",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,6 +36,10 @@
36
36
  "types": "./dist/runtime/entries/client.d.ts",
37
37
  "default": "./dist/runtime/entries/client.js"
38
38
  },
39
+ "./__state": {
40
+ "types": "./dist/runtime/state.d.ts",
41
+ "default": "./dist/runtime/state.js"
42
+ },
39
43
  "./__ssr": {
40
44
  "react-server": "./dist/runtime/entries/no-react-server.js",
41
45
  "types": "./dist/runtime/entries/ssr.d.ts",