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`) }