Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What This Project Is

A Cypress component testing dev server that uses Rspack as the bundler. Based on Cypress's official webpack-dev-server, re-implemented for Rspack. Published to npm as `cypress-rspack-dev-server`.

## Commands

- `pnpm build` — compile TypeScript to `dist/` (tolerates type errors)
- `pnpm check-ts` — type-check without emitting
- `pnpm lint` — ESLint
- `pnpm test` — Jest
- `pnpm cypress:run` — run Cypress component tests headless (requires `cypress install` first)
- `pnpm cypress:open` — open Cypress interactive mode
- `DEBUG=cypress-rspack-dev-server:* <command>` — enable debug logging for any command

## Architecture

The entry point is `src/index.ts` which exports `devServer()`. The flow:

0. **`patchImportMeta.ts`** — imported first by `index.ts`. Patches `Module._extensions['.js']` to fix tsx/esbuild's broken `import.meta.dirname` handling (see Workarounds section below)
1. **`devServer.ts`** — detects framework (react/vue/svelte/angular/next), resolves presets, orchestrates everything
2. **`helpers/sourceRelativeRspackModules.ts`** — sources `@rspack/core` and `@rspack/dev-server` from the user's project (or framework) via `dynamicAbsoluteImport`, installs `Module._load`/`_resolveFilename` hooks to ensure consistent rspack resolution
3. **`makeRspackConfig.ts`** — auto-discovers user's rspack/webpack config via `find-up`, merges user + framework + Cypress configs with `webpack-merge`, removes conflicting plugins (HtmlWebpackPlugin, HtmlRspackPlugin, HMR)
4. **`makeDefaultRspackConfig.ts`** — builds Cypress-specific rspack config: dev mode, inline source maps, HtmlRspackPlugin, CypressCTRspackPlugin
5. **`createRspackDevServer.ts`** — instantiates `RspackDevServer` with final config
6. **`CypressCTRspackPlugin.ts`** — custom rspack plugin that tracks spec files, handles `dev-server:specs:changed` events, injects context into the loader
7. **`loader.ts`** — rspack loader that generates dynamic imports for spec files with chunk names, handles support file injection
8. **`browser.ts` → `aut-runner.ts`** — browser-side entry that initializes `Cypress.onSpecWindow()`

## Key Design Decisions

**`dynamic-import.ts`** — uses `new Function('specifier', 'return import(specifier)')` to prevent tsc (CommonJS target) from converting `import()` to `require()`. This is critical for loading Rspack v2's ESM modules. Used in `sourceRelativeRspackModules.ts` and `makeRspackConfig.ts`.

**Module resolution hooks** — `sourceRelativeRspackModules.ts` monkey-patches `Module._load` and `Module._resolveFilename` so that any code importing `rspack` or `rspack/*` resolves to the version found in the user's project, not the bundled one.

**Config auto-discovery** — searches for `rspack.config.{ts,js,mjs,cjs}` first, falls back to `webpack.config.{ts,js,mjs,cjs}` (defined in `constants.ts`).

## Workarounds (tsx / import.meta.dirname)

Rspack v2 is a pure ESM package that uses `import.meta.dirname` internally. Cypress uses tsx to load config files. tsx/esbuild transforms `import.meta` into `const import_meta = {}` (an empty object) when converting ESM to CJS, leaving `import.meta.dirname` undefined and crashing Rspack.

Two workarounds are in place (TODO: remove once [tsx#782](https://github.com/privatenumber/tsx/pull/782) is merged and Cypress ships with the fixed tsx):

1. **`patchImportMeta.ts`** — wraps `Module._extensions['.js']` to detect the empty `import_meta={}` in tsx-transformed code and inject correct `dirname`/`filename`/`url` values. Must be the first import in `index.ts` so it's active before any `@rspack/*` modules are required (including from user config files).

2. **`dynamicAbsoluteImport`** in `sourceRelativeRspackModules.ts` — uses `new Function('specifier', 'return import(specifier)')` to preserve native `import()` at runtime, preventing tsc (CommonJS target) from converting `import()` to `require()`.

Related issues:
- https://github.com/privatenumber/tsx/issues/781
- https://github.com/web-infra-dev/rspack/issues/13420

## Build & Publish

- TypeScript compiles `src/` → `dist/` with CommonJS module output, ES2017 target
- Only `dist/` is published (`"files": ["dist"]` in package.json)
- `tsconfig.json` has `stripInternal: true` to remove `@internal` types from declarations

## Code Style

- Prettier: single quotes, no semicolons, 100 char width
- Debug logging uses the `debug` library with namespace `cypress-rspack-dev-server:<module>`
- Constants are UPPER_SNAKE_CASE, classes PascalCase, functions/variables camelCase
4 changes: 2 additions & 2 deletions dist/CypressCTRspackPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.CypressCTRspackPlugin = exports.normalizeError = void 0;
const tslib_1 = require("tslib");
const isEqual_1 = tslib_1.__importDefault(require("lodash/isEqual"));
const lodash_1 = require("lodash");
const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
const path_1 = tslib_1.__importDefault(require("path"));
const debug_1 = tslib_1.__importDefault(require("debug"));
Expand Down Expand Up @@ -62,7 +62,7 @@ class CypressCTRspackPlugin {
*/
this.onSpecsChange = async (specs) => {
var _a;
if (!this.compilation || (0, isEqual_1.default)(specs.specs, this.files)) {
if (!this.compilation || (0, lodash_1.isEqual)(specs.specs, this.files)) {
return;
}
this.files = specs.specs;
Expand Down
5 changes: 2 additions & 3 deletions dist/devServer.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { EventEmitter } from 'events';
import type { RspackDevServer } from '@rspack/dev-server';
import type { Compiler, Configuration } from '@rspack/core';
import type { Configuration } from '@rspack/core';
export type Frameworks = Extract<Cypress.DevServerConfigOptions, {
bundler: 'webpack';
}>['framework'];
Expand Down Expand Up @@ -30,6 +29,6 @@ export type DevServerConfig = {
*/
export declare function devServer(devServerConfig: DevServerConfig): Promise<Cypress.ResolvedDevServerConfig>;
export declare namespace devServer {
var create;
var create: (devServerConfig: DevServerConfig) => Promise<DevServerCreateResult>;
}
export {};
13 changes: 8 additions & 5 deletions dist/devServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,15 @@ function isThirdPartyDefinition(framework) {
thirdPartyDefinitionPrefixes.namespacedPrefixRe.test(framework));
}
async function getPreset(devServerConfig) {
const defaultRspackModules = () => ({
sourceRspackModulesResult: (0, sourceRelativeRspackModules_1.sourceDefaultRspackDependencies)(devServerConfig),
});
const defaultRspackModules = async () => {
const sourceRspackModulesResult = await (0, sourceRelativeRspackModules_1.sourceDefaultRspackDependencies)(devServerConfig);
return ({
sourceRspackModulesResult
});
};
// Third party library (eg solid-js, lit, etc)
if (devServerConfig.framework && isThirdPartyDefinition(devServerConfig.framework)) {
return defaultRspackModules();
return await defaultRspackModules();
}
switch (devServerConfig.framework) {
// todo - add support for other frameworks
Expand All @@ -69,7 +72,7 @@ async function getPreset(devServerConfig) {
case 'vue':
case 'svelte':
case undefined:
return defaultRspackModules();
return await defaultRspackModules();
default:
throw new Error(`Unexpected framework ${devServerConfig.framework}, please visit https://on.cypress.io/component-framework-configuration to see a list of supported frameworks`);
}
Expand Down
6 changes: 3 additions & 3 deletions dist/helpers/sourceRelativeRspackModules.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface SourceRelativeRspackResult {
rspackDevServer: SourcedRspackDevServer;
}
export declare function sourceFramework(config: DevServerConfig): SourcedDependency | null;
export declare function sourceRspack(config: DevServerConfig, framework: SourcedDependency | null): SourcedRspack;
export declare function sourceRspackDevServer(config: DevServerConfig, framework?: SourcedDependency | null): SourcedRspackDevServer;
export declare function sourceDefaultRspackDependencies(config: DevServerConfig): SourceRelativeRspackResult;
export declare function sourceRspack(config: DevServerConfig, framework: SourcedDependency | null): Promise<SourcedRspack>;
export declare function sourceRspackDevServer(config: DevServerConfig, framework?: SourcedDependency | null): Promise<SourcedRspackDevServer>;
export declare function sourceDefaultRspackDependencies(config: DevServerConfig): Promise<SourceRelativeRspackResult>;
export declare function restoreLoadHook(): void;
37 changes: 26 additions & 11 deletions dist/helpers/sourceRelativeRspackModules.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const tslib_1 = require("tslib");
const module_1 = tslib_1.__importDefault(require("module"));
const path_1 = tslib_1.__importDefault(require("path"));
const debug_1 = tslib_1.__importDefault(require("debug"));
const dynamic_import_1 = require("../dynamic-import");
const debug = (0, debug_1.default)('cypress-rspack-dev-server:sourceRelativeRspackModules');
const originalModuleLoad = module_1.default._load;
const originalModuleResolveFilename = module_1.default._resolveFilename;
Expand All @@ -19,6 +20,19 @@ const frameworkRspackMapper = {
angular: '@angular-devkit/build-angular',
svelte: undefined,
};
function resolveEntryPoint(importPath, packageJson) {
let entryFile = packageJson.main || 'index.js';
if (packageJson.exports) {
const exportsDot = packageJson.exports['.'] || packageJson.exports;
if (typeof exportsDot === 'string') {
entryFile = exportsDot;
}
else if (typeof exportsDot === 'object') {
entryFile = exportsDot.import || exportsDot.default || exportsDot.require || entryFile;
}
}
return path_1.default.resolve(importPath, entryFile);
}
// Source the users framework from the provided projectRoot. The framework, if available, will serve
// as the resolve base for rspack dependency resolution.
function sourceFramework(config) {
Expand Down Expand Up @@ -53,7 +67,7 @@ function sourceFramework(config) {
}
// Source the rspack module from the provided framework or projectRoot. We override the module resolution
// so that other packages that import rspack resolve to the version we found.
function sourceRspack(config, framework) {
async function sourceRspack(config, framework) {
var _a;
const searchRoot = (_a = framework === null || framework === void 0 ? void 0 : framework.importPath) !== null && _a !== void 0 ? _a : config.cypressConfig.projectRoot;
debug('Rspack: Attempting to source rspack from %s', searchRoot);
Expand All @@ -63,14 +77,14 @@ function sourceRspack(config, framework) {
});
rspack.importPath = path_1.default.dirname(rspackJsonPath);
rspack.packageJson = require(rspackJsonPath);
rspack.module = require(rspack.importPath).rspack;
debug('Rspack: Successfully sourced rspack - %o', rspack);
const rspackEntryPath = resolveEntryPoint(rspack.importPath, rspack.packageJson);
const mod = await (0, dynamic_import_1.dynamicAbsoluteImport)(rspackEntryPath);
rspack.module = mod.rspack;
module_1.default._load = function (request, parent, isMain) {
if (request === 'rspack' || request.startsWith('rspack/')) {
const resolvePath = require.resolve(request, {
paths: [rspack.importPath],
});
debug('Rspack: Module._load resolvePath - %s', resolvePath);
return originalModuleLoad(resolvePath, parent, isMain);
}
return originalModuleLoad(request, parent, isMain);
Expand All @@ -80,7 +94,6 @@ function sourceRspack(config, framework) {
const resolveFilename = originalModuleResolveFilename(request, parent, isMain, {
paths: [rspack.importPath],
});
debug('Rspack: Module._resolveFilename resolveFilename - %s', resolveFilename);
return resolveFilename;
}
return originalModuleResolveFilename(request, parent, isMain, options);
Expand All @@ -89,10 +102,9 @@ function sourceRspack(config, framework) {
}
// Source the @rspack/dev-server module from the provided framework or projectRoot.
// If none is found, we fallback to the version bundled with this package.
function sourceRspackDevServer(config, framework) {
async function sourceRspackDevServer(config, framework) {
var _a;
const searchRoot = (_a = framework === null || framework === void 0 ? void 0 : framework.importPath) !== null && _a !== void 0 ? _a : config.cypressConfig.projectRoot;
debug('RspackDevServer: Attempting to source @rspack/dev-server from %s', searchRoot);
const rspackDevServer = {};
let rspackDevServerJsonPath;
try {
Expand All @@ -112,15 +124,18 @@ function sourceRspackDevServer(config, framework) {
}
rspackDevServer.importPath = path_1.default.dirname(rspackDevServerJsonPath);
rspackDevServer.packageJson = require(rspackDevServerJsonPath);
rspackDevServer.module = require(rspackDevServer.importPath).RspackDevServer;
const rspackDevServerEntryPath = resolveEntryPoint(rspackDevServer.importPath, rspackDevServer.packageJson);
// WORKAROUND: see import comment at top of file
const mod = await (0, dynamic_import_1.dynamicAbsoluteImport)(rspackDevServerEntryPath);
rspackDevServer.module = mod.RspackDevServer;
debug('RspackDevServer: Successfully sourced @rspack/dev-server - %o', rspackDevServer);
return rspackDevServer;
}
// Most frameworks follow a similar path for sourcing rspack dependencies so this is a utility to handle all the sourcing.
function sourceDefaultRspackDependencies(config) {
async function sourceDefaultRspackDependencies(config) {
const framework = sourceFramework(config);
const rspack = sourceRspack(config, framework);
const rspackDevServer = sourceRspackDevServer(config, framework);
const rspack = await sourceRspack(config, framework);
const rspackDevServer = await sourceRspackDevServer(config, framework);
return { framework, rspack, rspackDevServer };
}
function restoreLoadHook() {
Expand Down
1 change: 1 addition & 0 deletions dist/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import './patchImportMeta';
import { devServer } from './devServer';
export { devServer };
export default devServer;
3 changes: 3 additions & 0 deletions dist/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.devServer = void 0;
// Must run before any @rspack/* modules are loaded — patches tsx's CJS transform
// to fix import.meta.dirname/filename. See patchImportMeta.ts for details.
require("./patchImportMeta");
const devServer_1 = require("./devServer");
Object.defineProperty(exports, "devServer", { enumerable: true, get: function () { return devServer_1.devServer; } });
exports.default = devServer_1.devServer;
2 changes: 2 additions & 0 deletions dist/makeDefaultRspackConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.makeCypressRspackConfig = makeCypressRspackConfig;
const tslib_1 = require("tslib");
const path_1 = tslib_1.__importDefault(require("path"));
const debug_1 = tslib_1.__importDefault(require("debug"));
const core_1 = require("@rspack/core");
const CypressCTRspackPlugin_1 = require("./CypressCTRspackPlugin");
const OUTPUT_PATH = __dirname;
const OsSeparatorRE = RegExp(`\\${path_1.default.sep}`, 'g');
const posixSeparator = '/';
const debug = (0, debug_1.default)('cypress-rspack-dev-server:makeDefaultRspackConfig');
function makeCypressRspackConfig(config) {
const { devServerConfig: { cypressConfig: { justInTimeCompile, projectRoot, devServerPublicPathRoute, supportFile, indexHtmlFile, isTextTerminal: isRunMode, }, specs: files, devServerEvents, }, } = config;
const optimization = {
Expand Down
23 changes: 23 additions & 0 deletions dist/patchImportMeta.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* WORKAROUND: Patch tsx's CJS transform to fix import.meta.dirname/filename.
*
* When Cypress loads config files, it uses tsx to transpile TypeScript/ESM to CJS.
* tsx uses esbuild which transforms `import.meta` into `const import_meta = {}` —
* an empty object. This leaves `import.meta.dirname` and `import.meta.filename`
* undefined, causing Rspack v2 (which relies on them) to crash with:
* TypeError: The "path" argument must be of type string. Received undefined
*
* This patch wraps Module._extensions['.js'] to intercept the transformed code
* and inject the correct dirname/filename/url values into the empty import_meta
* object before the module executes.
*
* This must run at module-load time (before any @rspack/* modules are required)
* to cover both this library's internal imports and user config files that
* import from @rspack/core.
*
* TODO: Remove this workaround once tsx merges the fix and Cypress ships with it.
* See: https://github.com/privatenumber/tsx/issues/781
* See: https://github.com/privatenumber/tsx/pull/782
* See: https://github.com/web-infra-dev/rspack/issues/13420
*/
export {};
56 changes: 56 additions & 0 deletions dist/patchImportMeta.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use strict";
/**
* WORKAROUND: Patch tsx's CJS transform to fix import.meta.dirname/filename.
*
* When Cypress loads config files, it uses tsx to transpile TypeScript/ESM to CJS.
* tsx uses esbuild which transforms `import.meta` into `const import_meta = {}` —
* an empty object. This leaves `import.meta.dirname` and `import.meta.filename`
* undefined, causing Rspack v2 (which relies on them) to crash with:
* TypeError: The "path" argument must be of type string. Received undefined
*
* This patch wraps Module._extensions['.js'] to intercept the transformed code
* and inject the correct dirname/filename/url values into the empty import_meta
* object before the module executes.
*
* This must run at module-load time (before any @rspack/* modules are required)
* to cover both this library's internal imports and user config files that
* import from @rspack/core.
*
* TODO: Remove this workaround once tsx merges the fix and Cypress ships with it.
* See: https://github.com/privatenumber/tsx/issues/781
* See: https://github.com/privatenumber/tsx/pull/782
* See: https://github.com/web-infra-dev/rspack/issues/13420
*/
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const module_1 = tslib_1.__importDefault(require("module"));
const path_1 = tslib_1.__importDefault(require("path"));
const extensions = module_1.default._extensions;
// Wrap the current .js handler. tsx may or may not be registered at this point —
// either way, we wrap whatever handler is active and patch _compile to fix the
// empty import_meta object that tsx/esbuild produces.
const previousJsHandler = extensions['.js'];
if (previousJsHandler) {
extensions['.js'] = function patchedJsHandler(mod, filename) {
const origCompile = mod._compile;
mod._compile = function (content, fn) {
// Restore original _compile immediately so we don't leak the wrapper
;
mod._compile = origCompile;
// Only patch files where tsx/esbuild has transformed import.meta to an empty object.
// This is a targeted string replacement that only affects transformed ESM code.
if (typeof content === 'string' && content.includes('const import_meta={}')) {
const dirname = path_1.default.dirname(filename);
content = content.replace('const import_meta={}', 'const import_meta={dirname:' +
JSON.stringify(dirname) +
',filename:' +
JSON.stringify(filename) +
',url:' +
JSON.stringify('file://' + filename) +
'}');
}
return origCompile.call(this, content, fn);
};
return previousJsHandler(mod, filename);
};
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@jest/globals": "^29.7.0",
"@rspack/core": "1.7.3",
"@rspack/dev-server": "1.2.1",
"@rspack/core": "2.0.0-rc.1",
"@rspack/dev-server": "2.0.0-rc.2",
"@types/debug": "^4.1.12",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.14",
Expand Down
Loading
Loading