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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"@tscircuit/miniflex": "^0.0.4",
"@tscircuit/ngspice-spice-engine": "^0.0.2",
"@tscircuit/parts-engine": "^0.0.11",
"@tscircuit/props": "^0.0.398",
"@tscircuit/props": "^0.0.402",
"@tscircuit/schematic-autolayout": "^0.0.6",
"@tscircuit/schematic-match-adapt": "^0.0.16",
"@tscircuit/schematic-trace-solver": "^v0.0.45",
Expand Down
204 changes: 204 additions & 0 deletions tests/features/node-modules-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { test, expect } from "bun:test"
import { CircuitRunner } from "lib/runner/CircuitRunner"

test("nodeModulesResolver: should resolve modules not in fsMap", async () => {
const runner = new CircuitRunner()

// Create a custom resolver that returns a mock module
const fakeResolver = async (modulePath: string) => {
if (modulePath === "test-package") {
return `
export const testValue = "resolved from custom resolver"
export default { message: "hello from resolver" }
`
}
throw new Error("Package not found")
}

runner._circuitRunnerConfiguration.platform = {
nodeModulesResolver: fakeResolver,
}

await runner.execute(`
import { testValue } from "test-package"

circuit.add(<board width="10mm" height="10mm" />)
`)

await runner.renderUntilSettled()

// If we get here without errors, the resolver worked
expect(
runner._executionContext?.preSuppliedImports["test-package"],
).toBeDefined()
})

test("nodeModulesResolver: should handle scoped packages", async () => {
const runner = new CircuitRunner()

const fakeResolver = async (modulePath: string) => {
if (modulePath === "@test/scoped-package") {
return `export const scopedValue = "from scoped package"`
}
throw new Error("Package not found")
}

runner._circuitRunnerConfiguration.platform = {
nodeModulesResolver: fakeResolver,
}

await runner.execute(`
import { scopedValue } from "@test/scoped-package"

circuit.add(<board width="10mm" height="10mm" />)
`)

await runner.renderUntilSettled()

expect(
runner._executionContext?.preSuppliedImports["@test/scoped-package"],
).toBeDefined()
})

test("nodeModulesResolver: should handle subpath imports", async () => {
const runner = new CircuitRunner()

const fakeResolver = async (modulePath: string) => {
if (modulePath === "@test/package/submodule") {
return `export const subValue = "from submodule"`
}
throw new Error("Package not found")
}

runner._circuitRunnerConfiguration.platform = {
nodeModulesResolver: fakeResolver,
}

await runner.execute(`
import { subValue } from "@test/package/submodule"

circuit.add(<board width="10mm" height="10mm" />)
`)

await runner.renderUntilSettled()

expect(
runner._executionContext?.preSuppliedImports["@test/package/submodule"],
).toBeDefined()
})

test("nodeModulesResolver: should handle nested subpaths", async () => {
const runner = new CircuitRunner()

const fakeResolver = async (modulePath: string) => {
if (modulePath === "regular-package/dist/index") {
return `export const nestedValue = "from nested path"`
}
throw new Error("Package not found")
}

runner._circuitRunnerConfiguration.platform = {
nodeModulesResolver: fakeResolver,
}

await runner.execute(`
import { nestedValue } from "regular-package/dist/index"

circuit.add(<board width="10mm" height="10mm" />)
`)

await runner.renderUntilSettled()

expect(
runner._executionContext?.preSuppliedImports["regular-package/dist/index"],
).toBeDefined()
})

test("nodeModulesResolver: should fallback to npm CDN when resolver throws", async () => {
const runner = new CircuitRunner()

const fakeResolver = async () => {
throw new Error("Package not found") // Always throw
}

runner._circuitRunnerConfiguration.platform = {
nodeModulesResolver: fakeResolver,
}

// Since the resolver throws, it should fall back to npm CDN
// Missing packages will fail with jsdelivr error
await expect(async () => {
await runner.execute(`
import { missing } from "missing-package"

circuit.add(<board width="10mm" height="10mm" />)
`)
}).toThrow() // Will throw jsdelivr error
})

test("nodeModulesResolver: should prefer fsMap over resolver", async () => {
const runner = new CircuitRunner()

let resolverCalled = false
const fakeResolver = async () => {
resolverCalled = true
return `export const value = "from resolver"`
}

runner._circuitRunnerConfiguration.platform = {
nodeModulesResolver: fakeResolver,
}

await runner.executeWithFsMap({
entrypoint: "index.tsx",
fsMap: {
"index.tsx": `
import { value } from "my-package"
circuit.add(<board width="10mm" height="10mm" />)
`,
"node_modules/my-package/index.ts": `export const value = "from fsMap"`,
"node_modules/my-package/package.json": JSON.stringify({
main: "index.ts",
}),
},
})

await runner.renderUntilSettled()

// Resolver should NOT have been called since the package exists in fsMap
expect(resolverCalled).toBe(false)
expect(
runner._executionContext?.preSuppliedImports["my-package"],
).toBeDefined()
})

test("nodeModulesResolver: should work with CDN-style resolver", async () => {
const runner = new CircuitRunner()

// Simulate a CDN resolver
const cdnResolver = async (modulePath: string) => {
// Simulate fetching from a CDN
if (modulePath === "lodash") {
return `
export function get(obj, path) {
return "mocked lodash get"
}
`
}
throw new Error("Package not found")
}

runner._circuitRunnerConfiguration.platform = {
nodeModulesResolver: cdnResolver,
}

await runner.execute(`
import { get } from "lodash"

circuit.add(<board width="10mm" height="10mm" />)
`)

await runner.renderUntilSettled()

expect(runner._executionContext?.preSuppliedImports.lodash).toBeDefined()
})
23 changes: 23 additions & 0 deletions webworker/import-eval-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,29 @@ export async function importEvalPath(
return importNodeModule(importName, ctx, depth)
}

// If not found in fsMap but might be a node module, try importNodeModule
// which will attempt to use nodeModulesResolver if configured
if (
!importName.startsWith(".") &&
!importName.startsWith("/") &&
!importName.startsWith("@tsci/")
) {
const platform = ctx.circuit?.platform
if (platform?.nodeModulesResolver) {
ctx.logger.info(
`importNodeModule("${importName}") via nodeModulesResolver`,
)
try {
await importNodeModule(importName, ctx, depth)
return
} catch (error) {
ctx.logger.info(
`nodeModulesResolver failed for "${importName}", falling back to npm CDN`,
)
}
}
}

if (importName.startsWith("@tsci/")) {
ctx.logger.info(`importSnippet("${importName}")`)
return importSnippet(importName, ctx, depth)
Expand Down
37 changes: 37 additions & 0 deletions webworker/import-node-module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { resolveNodeModule } from "lib/utils/resolve-node-module"
import type { ExecutionContext } from "./execution-context"
import { importLocalFile } from "./import-local-file"
import Debug from "debug"

const debug = Debug("tsci:eval:import-node-module")

export const importNodeModule = async (
importName: string,
Expand All @@ -16,6 +19,40 @@ export const importNodeModule = async (
const resolvedNodeModulePath = resolveNodeModule(importName, ctx.fsMap, "")

if (!resolvedNodeModulePath) {
const platform = ctx.circuit?.platform
if (platform?.nodeModulesResolver) {
debug(`Attempting to resolve "${importName}" using nodeModulesResolver`)

try {
const fileContent = await platform.nodeModulesResolver(importName)

if (fileContent) {
debug(`Successfully resolved "${importName}" via nodeModulesResolver`)

// Add the resolved content to fsMap with a synthetic path
// Add .ts extension to ensure it's treated as a module file
const syntheticPath = `node_modules/${importName}.ts`
ctx.fsMap[syntheticPath] = fileContent

// Import the file using the normal flow
await importLocalFile(syntheticPath, ctx, depth)

// Map the import name to the resolved module
preSuppliedImports[importName] = preSuppliedImports[syntheticPath]

// Also map without node_modules prefix
const unprefixedPath = syntheticPath.replace(/^node_modules\//, "")
preSuppliedImports[unprefixedPath] = preSuppliedImports[syntheticPath]
Comment on lines +32 to +45

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve package directory when injecting resolver output

When the custom nodeModulesResolver returns code for a bare specifier, the module is injected into the virtual FS as node_modules/${importName}.ts (lines 33‑46). That flattens the whole package into a single file at the root of node_modules. importLocalFile resolves relative imports based on dirname(fsPath), so any ./foo inside the fetched module now resolves to node_modules/foo instead of node_modules/<package>/foo. As a result, any real package source that contains relative imports (the common case for CDN bundles) will immediately fail to resolve its own dependencies when loaded via the resolver. The synthetic file needs to live inside a package directory (e.g. node_modules/<spec>/index.ts) so that relative paths match Node semantics.

Useful? React with 👍 / 👎.


return
}

debug(`nodeModulesResolver returned null for "${importName}"`)
} catch (error) {
debug(`nodeModulesResolver failed for "${importName}":`, error)
}
}

throw new Error(`Node module "${importName}" not found`)
}

Expand Down