Skip to content

Commit 0093f62

Browse files
committed
fix(script-runner): robustly transform imports for runtime execution
1 parent 7a659ff commit 0093f62

File tree

3 files changed

+122
-68
lines changed

3 files changed

+122
-68
lines changed

apps/remix-ide/src/app/editor/editor.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ export default class Editor extends Plugin {
8282

8383
this.typesLoadingCount = 0
8484
this.shimDisposers = new Map()
85-
this.pendingPackagesBatch = new Set()
8685
}
8786

87+
8888
setDispatch (dispatch) {
8989
this.dispatch = dispatch
9090
}
@@ -330,9 +330,15 @@ export default class Editor extends Plugin {
330330
await Promise.all(newBasePackages.map(async (basePackage) => {
331331
this.processedPackages.add(basePackage)
332332

333-
console.log(`[DIAGNOSE-DEEP-PASS] Starting deep pass for "${basePackage}"`)
333+
const activeRunnerLibs = await this.call('scriptRunnerBridge', 'getActiveRunnerLibs')
334+
335+
const libInfo = activeRunnerLibs.find(lib => lib.name === basePackage)
336+
const packageToLoad = libInfo ? `${libInfo.name}@${libInfo.version}` : basePackage
337+
338+
console.log(`[DIAGNOSE] Preparing to load types for: "${packageToLoad}"`)
339+
334340
try {
335-
const result = await startTypeLoadingProcess(basePackage)
341+
const result = await startTypeLoadingProcess(packageToLoad)
336342
if (result && result.libs && result.libs.length > 0) {
337343
console.log(`[DIAGNOSE-DEEP-PASS] "${basePackage}" deep pass complete. Adding ${result.libs.length} files.`)
338344
// Add all fetched type files to Monaco.

apps/remix-ide/src/app/editor/type-fetcher.ts

Lines changed: 43 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -136,55 +136,55 @@ export async function startTypeLoadingProcess(packageName: string): Promise<{ ma
136136
const collected: Library[] = []
137137
const subpathMap: Record<string, string> = {}
138138

139-
// The core inner function that recursively loads a package and its dependencies.
139+
// The core inner function that recursively loads a package and its dependencies.
140140
async function loadPackage(pkgNameToLoad: string) {
141-
if (visitedPackages.has(pkgNameToLoad)) return
142-
visitedPackages.add(pkgNameToLoad)
143-
144-
let pkgJson: PackageJson
145-
try {
146-
const pkgJsonUrl = new URL('package.json', `${CDN_BASE}${pkgNameToLoad}/`).href
147-
pkgJson = await fetchJson<PackageJson>(pkgJsonUrl)
148-
} catch (e) {
149-
console.log(`- Package '${pkgNameToLoad}' not found. Attempting @types fallback.`)
150-
// If the package is not found, attempt to find its @types equivalent.
151-
try { await loadPackage(toTypesScopedName(pkgNameToLoad)) } catch (ee) {}
152-
return
153-
}
141+
if (visitedPackages.has(pkgNameToLoad)) return
142+
visitedPackages.add(pkgNameToLoad)
143+
144+
let pkgJson: PackageJson
145+
try {
146+
const pkgJsonUrl = new URL('package.json', `${CDN_BASE}${pkgNameToLoad}/`).href
147+
pkgJson = await fetchJson<PackageJson>(pkgJsonUrl)
148+
} catch (e) {
149+
console.log(`- Package '${pkgNameToLoad}' not found. Attempting @types fallback.`)
150+
// If the package is not found, attempt to find its @types equivalent.
151+
try { await loadPackage(toTypesScopedName(pkgNameToLoad)) } catch (ee) {}
152+
return
153+
}
154154

155-
const exportMap = buildExportTypeMap(pkgNameToLoad, pkgJson)
155+
const exportMap = buildExportTypeMap(pkgNameToLoad, pkgJson)
156156

157-
// If the package is found but contains no type information, attempt the @types fallback.
158-
if (Object.keys(exportMap).length === 0) {
159-
console.log(`- No type declarations in '${pkgNameToLoad}'. Attempting @types fallback.`)
160-
try { await loadPackage(toTypesScopedName(pkgNameToLoad)) } catch (ee) {}
161-
return
162-
}
163-
164-
console.log(`[LOG 1] Starting full analysis for package: '${pkgNameToLoad}'`)
165-
const pendingDependencies = new Set<string>()
166-
const enqueuePackage = (p: string) => { if (!visitedPackages.has(p)) pendingDependencies.add(p) }
167-
168-
const crawlPromises: Promise<Library[]>[] = []
169-
// Crawl all entry points of the package to gather complete type information.
170-
for (const [subpath, urls] of Object.entries(exportMap)) {
171-
const entryPointUrl = urls[0]
172-
if (entryPointUrl) {
173-
const virtualPathKey = subpath === '.' ? pkgNameToLoad : `${pkgNameToLoad}/${subpath.replace('./', '')}`
174-
subpathMap[virtualPathKey] = entryPointUrl.replace(CDN_BASE, '')
175-
crawlPromises.push(crawl(entryPointUrl, pkgNameToLoad, new Set<string>(), enqueuePackage))
176-
}
157+
// If the package is found but contains no type information, attempt the @types fallback.
158+
if (Object.keys(exportMap).length === 0) {
159+
console.log(`- No type declarations in '${pkgNameToLoad}'. Attempting @types fallback.`)
160+
try { await loadPackage(toTypesScopedName(pkgNameToLoad)) } catch (ee) {}
161+
return
162+
}
163+
164+
console.log(`[LOG 1] Starting full analysis for package: '${pkgNameToLoad}'`)
165+
const pendingDependencies = new Set<string>()
166+
const enqueuePackage = (p: string) => { if (!visitedPackages.has(p)) pendingDependencies.add(p) }
167+
168+
const crawlPromises: Promise<Library[]>[] = []
169+
// Crawl all entry points of the package to gather complete type information.
170+
for (const [subpath, urls] of Object.entries(exportMap)) {
171+
const entryPointUrl = urls[0]
172+
if (entryPointUrl) {
173+
const virtualPathKey = subpath === '.' ? pkgNameToLoad.split('@')[0] : `${pkgNameToLoad.split('@')[0]}/${subpath.replace('./', '')}`
174+
subpathMap[virtualPathKey] = entryPointUrl.replace(CDN_BASE, '')
175+
crawlPromises.push(crawl(entryPointUrl, pkgNameToLoad, new Set<string>(), enqueuePackage))
177176
}
177+
}
178178

179-
const libsArrays = await Promise.all(crawlPromises)
180-
libsArrays.forEach(libs => collected.push(...libs))
181-
182-
// Recursively load any discovered dependency packages.
183-
if (pendingDependencies.size > 0) {
184-
console.log(`- Found dependencies for '${pkgNameToLoad}': ${Array.from(pendingDependencies).join(', ')}`)
185-
await Promise.all(Array.from(pendingDependencies).map(loadPackage))
186-
}
179+
const libsArrays = await Promise.all(crawlPromises)
180+
libsArrays.forEach(libs => collected.push(...libs))
181+
182+
// Recursively load any discovered dependency packages.
183+
if (pendingDependencies.size > 0) {
184+
console.log(`- Found dependencies for '${pkgNameToLoad}': ${Array.from(pendingDependencies).join(', ')}`)
185+
await Promise.all(Array.from(pendingDependencies).map(loadPackage))
187186
}
187+
}
188188

189189
await loadPackage(packageName)
190190

apps/remix-ide/src/app/plugins/script-runner-bridge.tsx

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { ScriptRunnerUIPlugin } from '../tabs/script-runner-ui'
1313
const profile = {
1414
name: 'scriptRunnerBridge',
1515
displayName: 'Script configuration',
16-
methods: ['execute', 'getConfigurations', 'selectScriptRunner'],
16+
methods: ['execute', 'getConfigurations', 'selectScriptRunner', 'getActiveRunnerLibs'],
1717
events: ['log', 'info', 'warn', 'error'],
1818
icon: 'assets/img/solid-gear-circle-play.svg',
1919
description: 'Configure the dependencies for running scripts.',
@@ -28,28 +28,69 @@ const configFileName = 'remix.config.json'
2828
let baseUrl = 'https://remix-project-org.github.io/script-runner-generator'
2929
const customBuildUrl = 'http://localhost:4000/build' // this will be used when the server is ready
3030

31-
// A helper function that transforms ESM 'import' syntax into a format executable in the browser when 'Run Script' is triggered.
32-
function transformScriptForRuntime(scriptContent: string): string {
33-
// Injects a helper function for dynamic imports at the top of the script.
31+
/**
32+
* @description A helper function that transforms script content for runtime execution.
33+
* It handles three types of ES module 'import' statements based on code review feedback:
34+
* 1. Relative path imports (e.g., './utils'): Converts to `require()` to use Remix's original module system.
35+
* 2. Pre-bundled library imports (e.g., 'ethers'): Converts to use global `window` objects to prevent version conflicts.
36+
* 3. External/NPM package imports (e.g., 'axios'): Converts to a dynamic `import()` from a CDN.
37+
* * @param {string} scriptContent - The original TypeScript/JavaScript content.
38+
* @param {string[]} preBundledDeps - A list of pre-bundled dependency names.
39+
* @returns {string} The transformed script content ready for execution.
40+
*/
41+
function transformScriptForRuntime(scriptContent: string, preBundledDeps: string[] = []): string {
42+
// Helper for dynamically importing external packages from a CDN.
3443
const dynamicImportHelper = `const dynamicImport = (p) => new Function(\`return import('https://cdn.jsdelivr.net/npm/\${p}/+esm')\`)();\n`
3544

36-
// Transforms 'import { member } from "pkg"' into 'const { member } = await dynamicImport("pkg")'.
45+
// Step 1: Transform 'import' statements
3746
let transformed = scriptContent.replace(
38-
/import\s+({[\s\S]*?})\s+from\s+['"]([^'"]+)['"]/g,
39-
'const $1 = await dynamicImport("$2");'
40-
)
41-
// Transforms 'import Default from "pkg"'.
42-
transformed = transformed.replace(
43-
/import\s+([\w\d_$]+)\s+from\s+['"]([^'"]+)['"]/g,
44-
'const $1 = (await dynamicImport("$2")).default;'
45-
)
46-
// Transforms 'import * as name from "pkg"'.
47-
transformed = transformed.replace(
48-
/import\s+\*\s+as\s+([\w\d_$]+)\s+from\s+['"]([^'"]+)['"]/g,
49-
'const $1 = await dynamicImport("$2");'
50-
)
51-
52-
// Wraps the entire script in an async IIFE (Immediately Invoked Function Expression) to support top-level await.
47+
/import\s+(?:({[\s\S]*?})|([\w\d_$]+)|(\*\s+as\s+[\w\d_$]+))\s+from\s+['"]([^'"]+)['"]/g,
48+
(match, namedMembers, defaultMember, namespaceMember, pkg) => {
49+
50+
// Case 1: Relative path import. This was a previously working feature.
51+
// By converting to `require()`, we let Remix's original script runner handle it.
52+
if (pkg.startsWith('./') || pkg.startsWith('../')) {
53+
if (namedMembers) return `const ${namedMembers} = require("${pkg}");`
54+
if (defaultMember) return `const ${defaultMember} = require("${pkg}");`
55+
if (namespaceMember) {
56+
const alias = namespaceMember.split(' as ')[1]
57+
return `const ${alias} = require("${pkg}");`
58+
}
59+
}
60+
61+
// Case 2: Pre-bundled library import (e.g., 'ethers').
62+
// Uses the global `window` object to avoid version conflicts and TDZ ReferenceErrors.
63+
if (preBundledDeps.includes(pkg)) {
64+
const libName = pkg.split('/').pop()
65+
const sourceObject = `window.${libName}`
66+
if (namedMembers) return `const ${namedMembers} = ${sourceObject};`
67+
if (defaultMember) return `const ${defaultMember} = ${sourceObject};`
68+
if (namespaceMember) {
69+
const alias = namespaceMember.split(' as ')[1]
70+
return `const ${alias} = ${sourceObject};`
71+
}
72+
}
73+
74+
// Case 3: External/NPM package import.
75+
// This is the new dynamic import feature for user-added packages.
76+
if (namedMembers) return `const ${namedMembers} = await dynamicImport("${pkg}");`
77+
if (defaultMember) return `const ${defaultMember} = (await dynamicImport("${pkg}")).default;`
78+
if (namespaceMember) {
79+
const alias = namespaceMember.split(' as ')[1]
80+
return `const ${alias} = await dynamicImport("${pkg}");`
81+
}
82+
83+
// Fallback for any unsupported import syntax.
84+
return `// Unsupported import for: ${pkg}`
85+
}
86+
);
87+
88+
// Step 2: Remove 'export' keyword
89+
// The script runner's execution context is not a module, so 'export' is a SyntaxError.
90+
transformed = transformed.replace(/^export\s+/gm, '')
91+
92+
// Step 3: Wrap in an async IIFE
93+
// This enables the use of top-level 'await' for dynamic imports.
5394
return `${dynamicImportHelper}\n(async () => {\n try {\n${transformed}\n } catch (e) { console.error('Error executing script:', e); }\n})();`
5495
}
5596

@@ -86,7 +127,6 @@ export class ScriptRunnerBridgePlugin extends Plugin {
86127
await this.loadConfigurations()
87128
const ui: ScriptRunnerUIPlugin = new ScriptRunnerUIPlugin(this)
88129
this.engine.register(ui)
89-
90130
}
91131

92132
setListeners() {
@@ -138,6 +178,13 @@ export class ScriptRunnerBridgePlugin extends Plugin {
138178
})
139179
}
140180

181+
public getActiveRunnerLibs() {
182+
if (this.activeConfig && this.activeConfig.dependencies) {
183+
return this.activeConfig.dependencies
184+
}
185+
return []
186+
}
187+
141188
public getConfigurations() {
142189
return this.configurations
143190
}
@@ -224,7 +271,8 @@ export class ScriptRunnerBridgePlugin extends Plugin {
224271
this.setIsLoading(this.activeConfig.name, true)
225272

226273
// Transforms the script into an executable format using the function defined above.
227-
const transformedScript = transformScriptForRuntime(script)
274+
const preBundledDeps = this.activeConfig.dependencies.map(dep => dep.name)
275+
const transformedScript = transformScriptForRuntime(script, preBundledDeps)
228276

229277
console.log('--- [ScriptRunner] Original Script ---')
230278
console.log(script)

0 commit comments

Comments
 (0)