From e226ecd7ac72df85341cd87bc58dbcbba31bcd0c Mon Sep 17 00:00:00 2001 From: imrishabh18 Date: Thu, 13 Nov 2025 19:44:46 +0530 Subject: [PATCH 1/2] feat: support `nodeModulesResolver` --- package.json | 2 +- tests/features/node-modules-resolver.test.ts | 204 +++++++++++++++++++ webworker/import-eval-path.ts | 23 +++ webworker/import-node-module.ts | 39 ++++ 4 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 tests/features/node-modules-resolver.test.ts diff --git a/package.json b/package.json index 141c8f9d..9e77183b 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 00000000..feea9ebd --- /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 mockResolver = 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: mockResolver, + } + + 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 mockResolver = 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: mockResolver, + } + + 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 mockResolver = async (modulePath: string) => { + if (modulePath === "@test/package/submodule") { + return `export const subValue = "from submodule"` + } + throw new Error("Package not found") + } + + runner._circuitRunnerConfiguration.platform = { + nodeModulesResolver: mockResolver, + } + + 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 mockResolver = 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: mockResolver, + } + + 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 mockResolver = async () => { + throw new Error("Package not found") // Always throw + } + + runner._circuitRunnerConfiguration.platform = { + nodeModulesResolver: mockResolver, + } + + // 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 mockResolver = async () => { + resolverCalled = true + return `export const value = "from resolver"` + } + + runner._circuitRunnerConfiguration.platform = { + nodeModulesResolver: mockResolver, + } + + 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 d93f245f..839404bc 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 6f253947..ab954ef3 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,42 @@ export const importNodeModule = async ( const resolvedNodeModulePath = resolveNodeModule(importName, ctx.fsMap, "") if (!resolvedNodeModulePath) { + // Try nodeModulesResolver if available + 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) + // Continue to throw the original error below + } + } + throw new Error(`Node module "${importName}" not found`) } From 67a5c682bc8edaac8429f71645aa59e13d73a343 Mon Sep 17 00:00:00 2001 From: imrishabh18 Date: Thu, 13 Nov 2025 19:52:19 +0530 Subject: [PATCH 2/2] rename variables --- tests/features/node-modules-resolver.test.ts | 26 ++++++++++---------- webworker/import-node-module.ts | 2 -- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/tests/features/node-modules-resolver.test.ts b/tests/features/node-modules-resolver.test.ts index feea9ebd..019042af 100644 --- a/tests/features/node-modules-resolver.test.ts +++ b/tests/features/node-modules-resolver.test.ts @@ -5,7 +5,7 @@ test("nodeModulesResolver: should resolve modules not in fsMap", async () => { const runner = new CircuitRunner() // Create a custom resolver that returns a mock module - const mockResolver = async (modulePath: string) => { + const fakeResolver = async (modulePath: string) => { if (modulePath === "test-package") { return ` export const testValue = "resolved from custom resolver" @@ -16,7 +16,7 @@ test("nodeModulesResolver: should resolve modules not in fsMap", async () => { } runner._circuitRunnerConfiguration.platform = { - nodeModulesResolver: mockResolver, + nodeModulesResolver: fakeResolver, } await runner.execute(` @@ -36,7 +36,7 @@ test("nodeModulesResolver: should resolve modules not in fsMap", async () => { test("nodeModulesResolver: should handle scoped packages", async () => { const runner = new CircuitRunner() - const mockResolver = async (modulePath: string) => { + const fakeResolver = async (modulePath: string) => { if (modulePath === "@test/scoped-package") { return `export const scopedValue = "from scoped package"` } @@ -44,7 +44,7 @@ test("nodeModulesResolver: should handle scoped packages", async () => { } runner._circuitRunnerConfiguration.platform = { - nodeModulesResolver: mockResolver, + nodeModulesResolver: fakeResolver, } await runner.execute(` @@ -63,7 +63,7 @@ test("nodeModulesResolver: should handle scoped packages", async () => { test("nodeModulesResolver: should handle subpath imports", async () => { const runner = new CircuitRunner() - const mockResolver = async (modulePath: string) => { + const fakeResolver = async (modulePath: string) => { if (modulePath === "@test/package/submodule") { return `export const subValue = "from submodule"` } @@ -71,7 +71,7 @@ test("nodeModulesResolver: should handle subpath imports", async () => { } runner._circuitRunnerConfiguration.platform = { - nodeModulesResolver: mockResolver, + nodeModulesResolver: fakeResolver, } await runner.execute(` @@ -90,7 +90,7 @@ test("nodeModulesResolver: should handle subpath imports", async () => { test("nodeModulesResolver: should handle nested subpaths", async () => { const runner = new CircuitRunner() - const mockResolver = async (modulePath: string) => { + const fakeResolver = async (modulePath: string) => { if (modulePath === "regular-package/dist/index") { return `export const nestedValue = "from nested path"` } @@ -98,7 +98,7 @@ test("nodeModulesResolver: should handle nested subpaths", async () => { } runner._circuitRunnerConfiguration.platform = { - nodeModulesResolver: mockResolver, + nodeModulesResolver: fakeResolver, } await runner.execute(` @@ -117,12 +117,12 @@ test("nodeModulesResolver: should handle nested subpaths", async () => { test("nodeModulesResolver: should fallback to npm CDN when resolver throws", async () => { const runner = new CircuitRunner() - const mockResolver = async () => { + const fakeResolver = async () => { throw new Error("Package not found") // Always throw } runner._circuitRunnerConfiguration.platform = { - nodeModulesResolver: mockResolver, + nodeModulesResolver: fakeResolver, } // Since the resolver throws, it should fall back to npm CDN @@ -140,13 +140,13 @@ test("nodeModulesResolver: should prefer fsMap over resolver", async () => { const runner = new CircuitRunner() let resolverCalled = false - const mockResolver = async () => { + const fakeResolver = async () => { resolverCalled = true return `export const value = "from resolver"` } runner._circuitRunnerConfiguration.platform = { - nodeModulesResolver: mockResolver, + nodeModulesResolver: fakeResolver, } await runner.executeWithFsMap({ @@ -200,5 +200,5 @@ test("nodeModulesResolver: should work with CDN-style resolver", async () => { await runner.renderUntilSettled() - expect(runner._executionContext?.preSuppliedImports["lodash"]).toBeDefined() + expect(runner._executionContext?.preSuppliedImports.lodash).toBeDefined() }) diff --git a/webworker/import-node-module.ts b/webworker/import-node-module.ts index ab954ef3..122f1eed 100644 --- a/webworker/import-node-module.ts +++ b/webworker/import-node-module.ts @@ -19,7 +19,6 @@ export const importNodeModule = async ( const resolvedNodeModulePath = resolveNodeModule(importName, ctx.fsMap, "") if (!resolvedNodeModulePath) { - // Try nodeModulesResolver if available const platform = ctx.circuit?.platform if (platform?.nodeModulesResolver) { debug(`Attempting to resolve "${importName}" using nodeModulesResolver`) @@ -51,7 +50,6 @@ export const importNodeModule = async ( debug(`nodeModulesResolver returned null for "${importName}"`) } catch (error) { debug(`nodeModulesResolver failed for "${importName}":`, error) - // Continue to throw the original error below } }