Skip to content
Open
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
106 changes: 105 additions & 1 deletion apps/remix-ide/src/app/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { Plugin } from '@remixproject/engine'
import * as packageJson from '../../../../../package.json'
import { PluginViewWrapper } from '@remix-ui/helper'

import { startTypeLoadingProcess } from './type-fetcher'

const EventManager = require('../../lib/events')

const profile = {
Expand Down Expand Up @@ -71,12 +73,21 @@ export default class Editor extends Plugin {
this.api = {}
this.dispatch = null
this.ref = null

this.monaco = null
this.typeLoaderDebounce = null

this.tsModuleMappings = {}
}

setDispatch (dispatch) {
this.dispatch = dispatch
}

setMonaco (monaco) {
this.monaco = monaco
}

updateComponent(state) {
return <EditorUI
editorAPI={state.api}
Expand All @@ -86,6 +97,7 @@ export default class Editor extends Plugin {
events={state.events}
plugin={state.plugin}
isDiff={state.isDiff}
setMonaco={(monaco) => this.setMonaco(monaco)}
/>
}

Expand Down Expand Up @@ -128,6 +140,18 @@ export default class Editor extends Plugin {

async onActivation () {
this.activated = true
this.on('editor', 'editorMounted', () => {
if (!this.monaco) return
const tsDefaults = this.monaco.languages.typescript.typescriptDefaults

tsDefaults.setCompilerOptions({
moduleResolution: this.monaco.languages.typescript.ModuleResolutionKind.NodeJs,
target: this.monaco.languages.typescript.ScriptTarget.ES2020,
allowNonTsExtensions: true,
baseUrl: 'file:///node_modules/',
paths: {}
})
})
this.on('sidePanel', 'focusChanged', (name) => {
this.keepDecorationsFor(name, 'sourceAnnotationsPerFile')
this.keepDecorationsFor(name, 'markerPerFile')
Expand Down Expand Up @@ -156,8 +180,88 @@ export default class Editor extends Plugin {
this.off('sidePanel', 'pluginDisabled')
}

updateTsCompilerOptions() {
console.log('[Module Mapper] Updating TS compiler options with new paths:', this.tsModuleMappings)
const tsDefaults = this.monaco.languages.typescript.typescriptDefaults
const oldOptions = tsDefaults.getCompilerOptions()

const newOptions = {
...oldOptions,
baseUrl: 'file:///node_modules/',
paths: this.tsModuleMappings
}

console.log('[DEBUG 3] Updating TS compiler with new options:', JSON.stringify(newOptions, null, 2))
tsDefaults.setCompilerOptions(newOptions)

setTimeout(() => {
const allLibs = tsDefaults.getExtraLibs()
console.log('[DEBUG 4] Final check - Monaco extraLibs state:', Object.keys(allLibs).length, 'libs loaded.')
}, 2000)
}

async _onChange (file) {
this.triggerEvent('didChangeFile', [file])

if (this.monaco && (file.endsWith('.ts') || file.endsWith('.js'))) {
clearTimeout(this.typeLoaderDebounce)
this.typeLoaderDebounce = setTimeout(async () => {
if (!this.monaco) return
const model = this.monaco.editor.getModel(this.monaco.Uri.parse(file))
if (!model) return
const code = model.getValue()

try {

const extractPackageName = (importPath) => {
if (importPath.startsWith('@')) {
const parts = importPath.split('/')
return `${parts[0]}/${parts[1]}`
}
return importPath.split('/')[0]
}

const rawImports = [...code.matchAll(/from\s+['"]((?![./]).*?)['"]/g)].map(match => match[1])

const uniquePackages = [...new Set(rawImports.map(extractPackageName))]
console.log('[DEBUG 1] Extracted Package Names:', uniquePackages)
const newPackages = uniquePackages.filter(p => !this.tsModuleMappings[p])
if (newPackages.length === 0) return

console.log('[Module Mapper] New packages detected:', newPackages)

let newPathsFound = false
const promises = newPackages.map(async (pkg) => {
try {
const result = await startTypeLoadingProcess(pkg, this.monaco)

if (result && result.virtualPath) {
// this.tsModuleMappings[pkg] = [`${pkg}/`]
// this.tsModuleMappings[`${pkg}/*`] = [`${pkg}/*`]
const typeFileRelativePath = result.virtualPath.replace('file:///node_modules/', '')

this.tsModuleMappings[pkg] = [typeFileRelativePath]
this.tsModuleMappings[`${pkg}/*`] = [`${pkg}/*`]

newPathsFound = true
}
} catch (error) {
console.error(`[Module Mapper] Failed to process types for ${pkg}`, error)
}
})

await Promise.all(promises)

if (newPathsFound) {
setTimeout(() => this.updateTsCompilerOptions(), 1000)
}

} catch (error) {
console.error('[Type Loader] Error during type loading process:', error)
}
}, 1500)
}

const currentFile = await this.call('fileManager', 'file')
if (!currentFile) {
return
Expand Down Expand Up @@ -232,7 +336,7 @@ export default class Editor extends Plugin {
this.emit('addModel', contentDep, 'typescript', pathDep, this.readOnlySessions[path])
}
} else {
console.log("The file ", pathDep, " can't be found.")
// console.log("The file ", pathDep, " can't be found.")
}
} catch (e) {
console.log(e)
Expand Down
184 changes: 184 additions & 0 deletions apps/remix-ide/src/app/editor/type-fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { Monaco } from '@monaco-editor/react'

const processedPackages = new Set<string>()
const loadedLibs = new Set<string>()
const NODE_BUILTINS = new Set(['util', 'events', 'buffer', 'stream', 'path', 'fs', 'os', 'crypto', 'http', 'https', 'url', 'zlib'])

class NoTypesError extends Error {
constructor(message: string) {
super(message)
this.name = 'NoTypesError'
}
}

function getTypesPackageName(packageName: string): string {
if (packageName.startsWith('@')) {
const mangledName = packageName.substring(1).replace('/', '__');
return `@types/${mangledName}`
}
return `@types/${packageName}`
}

export async function startTypeLoadingProcess(packageName: string, monaco: Monaco): Promise<{ virtualPath: string; hasExports: boolean } | void> {
if (NODE_BUILTINS.has(packageName)) {
packageName = '@types/node'
}

if (processedPackages.has(packageName)) return
processedPackages.add(packageName)

console.log(`[Type Fetcher] Starting type loading process for "${packageName}"...`)

try {
return await loadTypesInBackground(packageName, monaco)
} catch (error) {
if (error.message.includes('No type definition') || error.message.includes('Failed to fetch any type definition')) {
console.warn(`[Type Fetcher] No types found for "${packageName}". Reason:`, error.message)
const typesPackageName = getTypesPackageName(packageName)
if (packageName === typesPackageName) return

console.log(`[Type Fetcher] Trying ${typesPackageName} as a fallback...`)
return startTypeLoadingProcess(typesPackageName, monaco)
} else {
console.error(`[Type Fetcher] Loading process failed for "${packageName}" and will not fallback to @types. Error:`, error.message)
}
}
}

async function resolveAndFetchDts(resolvedUrl: string): Promise<{ finalUrl: string; content: string }> {
const urlWithoutTrailingSlash = resolvedUrl.endsWith('/') ? resolvedUrl.slice(0, -1) : resolvedUrl
const attempts: string[] = []

if (/\.(m|c)?js$/.test(urlWithoutTrailingSlash)) {
attempts.push(urlWithoutTrailingSlash.replace(/\.(m|c)?js$/, '.d.ts'))
attempts.push(urlWithoutTrailingSlash.replace(/\.(m|c)?js$/, '.d.mts'))
attempts.push(urlWithoutTrailingSlash.replace(/\.(m|c)?js$/, '.d.cts'))
} else if (!/\.d\.(m|c)?ts$/.test(urlWithoutTrailingSlash)) {
attempts.push(`${urlWithoutTrailingSlash}.d.ts`)
attempts.push(`${urlWithoutTrailingSlash}.d.mts`)
attempts.push(`${urlWithoutTrailingSlash}.d.cts`)
attempts.push(`${urlWithoutTrailingSlash}/index.d.ts`)
attempts.push(`${urlWithoutTrailingSlash}/index.d.mts`)
attempts.push(`${urlWithoutTrailingSlash}/index.d.cts`)
} else {
attempts.push(urlWithoutTrailingSlash)
}

for (const url of attempts) {
try {
const response = await fetch(url)
if (response.ok) {
return { finalUrl: url, content: await response.text() }
}
} catch (e) {}
}
throw new Error(`Could not resolve DTS file for ${resolvedUrl}`)
}

async function loadTypesInBackground(packageName: string, monaco: Monaco): Promise<{ virtualPath: string; hasExports: boolean } | void> {
const baseUrl = `https://cdn.jsdelivr.net/npm/${packageName}/`
const packageJsonUrl = `${baseUrl}package.json`
const response = await fetch(packageJsonUrl)

if (!response.ok) throw new Error(`Failed to fetch package.json for "${packageName}"`)

const packageJson = await response.json()

console.log(`[Type Fetcher] Fetched package.json for "${packageName}", version: ${packageJson.version}`)
addLibToMonaco(`file:///node_modules/${packageName}/package.json`, JSON.stringify(packageJson), monaco)

const typePathsToFetch = new Set<string>()

const hasExports = typeof packageJson.exports === 'object' && packageJson.exports !== null
console.log(`[Type Fetcher DBG] 'exports' field detected: ${hasExports}`)

if (hasExports) {
for (const key in packageJson.exports) {
if (key.includes('*') || key.endsWith('package.json')) continue

const entry = packageJson.exports[key]

let typePath: string | null = null

if (typeof entry === 'string') {
if (!entry.endsWith('.json')) {
typePath = entry.replace(/\.(m|c)?js$/, '.d.ts')
}
} else if (typeof entry === 'object' && entry !== null) {
if (typeof entry.types === 'string') {
typePath = entry.types
}
else if (typeof entry.import === 'string') {
typePath = entry.import.replace(/\.(m|c)?js$/, '.d.ts')
} else if (typeof entry.default === 'string') {
typePath = entry.default.replace(/\.(m|c)?js$/, '.d.ts')
}
}

if (typePath) {
console.log(`[Type Fetcher DBG] Found type path for exports['${key}']: ${typePath}`)
typePathsToFetch.add(typePath)
}
}
}

const mainTypePath = packageJson.types || packageJson.typings
console.log(`[Type Fetcher DBG] Top-level 'types' field: ${mainTypePath}`)
if (typeof mainTypePath === 'string') {
typePathsToFetch.add(mainTypePath)
}

console.log(`[Type Fetcher DBG] Total type paths found: ${typePathsToFetch.size}`)
if (typePathsToFetch.size === 0) {
const mainField = packageJson.main
if (typeof mainField === 'string') {
console.log(`[Type Fetcher DBG] Inferring from 'main' field: ${mainField}`)
typePathsToFetch.add(mainField.replace(/\.(m|c)?js$/, '.d.ts'))
}

if (typePathsToFetch.size === 0) {
throw new NoTypesError(`No type definition entry found in package.json.`)
}
}

let mainVirtualPath = ''
for (const relativePath of typePathsToFetch) {
let cleanPath = relativePath
if (!cleanPath.startsWith('./')) cleanPath = './' + cleanPath

const fileUrl = new URL(cleanPath, baseUrl).href
try {
const { finalUrl, content } = await resolveAndFetchDts(fileUrl)
const virtualPath = finalUrl.replace('https://cdn.jsdelivr.net/npm', 'file:///node_modules')
addLibToMonaco(virtualPath, content, monaco)
if (!mainVirtualPath) mainVirtualPath = virtualPath
} catch (error) {
console.warn(`[Type Fetcher] Could not fetch sub-type file: ${fileUrl}`, error)
}
}

if (!mainVirtualPath) throw new Error('Failed to fetch any type definition files.')

console.log(`[Type Fetcher] Completed fetching all type definitions for ${packageName}.`)

if (packageJson.dependencies) {
console.log(`[Type Fetcher] Found ${Object.keys(packageJson.dependencies).length} dependencies for ${packageName}. Fetching them...`)
const depPromises = Object.keys(packageJson.dependencies).map(dep => {
try {
return startTypeLoadingProcess(dep, monaco)
} catch(e) {
console.warn(`[Type Fetcher] Failed to start loading types for dependency: ${dep}`, e.message)
return Promise.resolve()
}
})
await Promise.all(depPromises)
}

return { virtualPath: mainVirtualPath, hasExports }
}

function addLibToMonaco(filePath: string, content: string, monaco: Monaco) {
if (loadedLibs.has(filePath)) return
monaco.languages.typescript.typescriptDefaults.addExtraLib(content, filePath)
loadedLibs.add(filePath)
}
40 changes: 39 additions & 1 deletion apps/remix-ide/src/app/plugins/script-runner-bridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,36 @@ const configFileName = 'remix.config.json'
let baseUrl = 'https://remix-project-org.github.io/script-runner-generator'
const customBuildUrl = 'http://localhost:4000/build' // this will be used when the server is ready

/**
* Transforms standard import statements into dynamic import statements for runtime execution.
* @param {string} scriptContent The original script content.
* @returns {string} The transformed script content.
*/
function transformScriptForRuntime(scriptContent: string): string {
// 1. dynamicImport 헬퍼 함수를 스크립트 맨 위에 주입
const dynamicImportHelper = `const dynamicImport = (p) => new Function(\`return import('https://cdn.jsdelivr.net/npm/\${p}/+esm')\`)();\n`;

// 2. 다양한 import 구문을 변환
// 'import { ... } from "package"' 구문
let transformed = scriptContent.replace(
/import\s+({[\s\S]*?})\s+from\s+['"]([^'"]+)['"]/g,
'const $1 = await dynamicImport("$2");'
);
// 'import Default from "package"' 구문
transformed = transformed.replace(
/import\s+([\w\d_$]+)\s+from\s+['"]([^'"]+)['"]/g,
'const $1 = (await dynamicImport("$2")).default;'
);
// 'import * as name from "package"' 구문
transformed = transformed.replace(
/import\s+\*\s+as\s+([\w\d_$]+)\s+from\s+['"]([^'"]+)['"]/g,
'const $1 = await dynamicImport("$2");'
);

// 3. 모든 코드를 async IIFE로 감싸서 top-level await 문제 해결
return `${dynamicImportHelper}\n(async () => {\n try {\n${transformed}\n } catch (e) { console.error('Error executing script:', e); }\n})();`;
}

export class ScriptRunnerBridgePlugin extends Plugin {
engine: Engine
dispatch: React.Dispatch<any> = () => {}
Expand Down Expand Up @@ -197,7 +227,15 @@ export class ScriptRunnerBridgePlugin extends Plugin {
}
try {
this.setIsLoading(this.activeConfig.name, true)
await this.call(`${this.scriptRunnerProfileName}${this.activeConfig.name}`, 'execute', script, filePath)
const transformedScript = transformScriptForRuntime(script);

console.log('--- [ScriptRunner] Original Script ---');
console.log(script);
console.log('--- [ScriptRunner] Transformed Script for Runtime ---');
console.log(transformedScript);

await this.call(`${this.scriptRunnerProfileName}${this.activeConfig.name}`, 'execute',transformedScript, filePath)

} catch (e) {
console.error('Error executing script', e)
}
Expand Down
Loading