diff --git a/package.json b/package.json
index 141c8f9..9e77183 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/tests/features/node-modules-resolver.test.ts b/tests/features/node-modules-resolver.test.ts
new file mode 100644
index 0000000..019042a
--- /dev/null
+++ b/tests/features/node-modules-resolver.test.ts
@@ -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()
+ `)
+
+ 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()
+ `)
+
+ 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()
+ `)
+
+ 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()
+ `)
+
+ 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()
+ `)
+ }).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()
+ `,
+ "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()
+ `)
+
+ await runner.renderUntilSettled()
+
+ expect(runner._executionContext?.preSuppliedImports.lodash).toBeDefined()
+})
diff --git a/webworker/import-eval-path.ts b/webworker/import-eval-path.ts
index d93f245..839404b 100644
--- a/webworker/import-eval-path.ts
+++ b/webworker/import-eval-path.ts
@@ -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)
diff --git a/webworker/import-node-module.ts b/webworker/import-node-module.ts
index 6f25394..122f1ee 100644
--- a/webworker/import-node-module.ts
+++ b/webworker/import-node-module.ts
@@ -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,
@@ -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]
+
+ return
+ }
+
+ debug(`nodeModulesResolver returned null for "${importName}"`)
+ } catch (error) {
+ debug(`nodeModulesResolver failed for "${importName}":`, error)
+ }
+ }
+
throw new Error(`Node module "${importName}" not found`)
}