Skip to content

feat: add dynamic @src alias resolution for consuming apps#158

Draft
arbrandes wants to merge 1 commit intoopenedx:mainfrom
arbrandes:dynamic-src-alias
Draft

feat: add dynamic @src alias resolution for consuming apps#158
arbrandes wants to merge 1 commit intoopenedx:mainfrom
arbrandes:dynamic-src-alias

Conversation

@arbrandes
Copy link
Contributor

@arbrandes arbrandes commented Feb 4, 2026

Description

Replace static @src webpack alias with ClosestSrcResolverPlugin that dynamically resolves @src to the nearest src directory from the importing file. Each consuming app now defines its own @src path in tsconfig.json for TypeScript/IDE support.

LLM usage notice

Created with assistance from Claude Opus 4.5 (session log).

@arbrandes arbrandes marked this pull request as draft February 4, 2026 19:21
Replace static @src webpack alias with ClosestSrcResolverPlugin that
dynamically resolves @src to the nearest src directory from the importing
file. Each consuming app now defines its own @src path in tsconfig.json
for TypeScript/IDE support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@brian-smith-tcril
Copy link
Contributor

I asked ChatGPT to take a look at the diff (I was wondering if there was a simpler way to handle this we were missing). It didn't find a simpler way, but it did make a few suggestions:

Summary

This plugin seems justified — webpack aliases are static and can’t resolve @src per consuming app. Enhanced-resolve is designed for custom resolution plugins.
That said, we could simplify + improve performance by:

  • matching @src more precisely (^@src($|/))
  • caching the closest src lookup per issuer directory (resolver runs a lot)
  • using path.join instead of string replace for cross-platform safety

No built-in enhanced-resolve plugin seems to provide “closest ancestor src wins”, so this approach makes sense overall.

Full conversation

i'm reviewing a PR, this is the link to the raw diff, i'm wondering if the webpack plugin is as simple as possible or if it can be simplified

https://patch-diff.githubusercontent.com/raw/openedx/frontend-base/pull/158.diff

From what’s in the diff, the plugin is already pretty small, but there are a few easy simplifications / robustness tweaks that also make it more correct and cheaper at build time. ([GitHub]1)

What can be simplified / improved

1) Match @src more precisely

Right now it triggers on anything that starts with @src (so @srcfoo would also match). ([GitHub]1)
Prefer ^@src($|/).

2) Avoid string replace with absolute paths (Windows/path-separator issues)

This line:

const newRequest = request.request.replace(/^@src/, srcPath);

will create mixed separators on Windows (C:\...\src + /components/...). Better: compute the remainder and path.join() it.

3) Don’t do existsSync + statSync (double filesystem hit)

You can use fs.statSync alone in a try/catch, or fs.existsSync alone if “file vs directory” isn’t important (it is here). Currently it’s two sync calls per directory level. ([GitHub]1)

4) Cache the result per issuer directory

Resolvers get called a lot. A tiny Map cache makes this basically free after first lookup.

5) Micro-simplification: inline the request object

You don’t need obj = { ...request, ... } as a separate variable.


A tightened version (same behavior, a bit safer + faster)

import fs from 'fs';
import path from 'path';
import type { Resolver } from 'webpack';

const SRC_PREFIX = /^@src(?:$|\/)/;

class ClosestSrcResolverPlugin {
  private cache = new Map<string, string | null>();

  apply(resolver: Resolver) {
    const target = resolver.ensureHook('resolve');

    resolver.getHook('resolve').tapAsync(
      'ClosestSrcResolverPlugin',
      (request: any, resolveContext: any, callback: (err?: Error | null, result?: any) => void) => {
        const reqStr: string | undefined = request.request;
        if (!reqStr || !SRC_PREFIX.test(reqStr)) return callback();

        const issuer: string | undefined = request.context?.issuer;
        if (!issuer) return callback();

        const issuerDir = path.dirname(issuer);

        let srcPath = this.cache.get(issuerDir);
        if (srcPath === undefined) {
          srcPath = findClosestSrcDir(issuerDir);
          this.cache.set(issuerDir, srcPath);
        }
        if (!srcPath) return callback();

        // turn "@src/whatever" into "<srcPath>/whatever"
        const remainder = reqStr.replace(SRC_PREFIX, ''); // '' or 'whatever...'
        const resolved = remainder ? path.join(srcPath, remainder) : srcPath;

        return resolver.doResolve(
          target,
          { ...request, request: resolved },
          null,
          resolveContext,
          callback
        );
      }
    );
  }
}

function findClosestSrcDir(fromDir: string): string | null {
  let dir = fromDir;
  const root = path.parse(dir).root;

  while (true) {
    const candidate = path.join(dir, 'src');
    try {
      if (fs.statSync(candidate).isDirectory()) return candidate;
    } catch {
      // ignore ENOENT, etc.
    }

    if (dir === root) return null;
    dir = path.dirname(dir);
  }
}

export default ClosestSrcResolverPlugin;

Why this is “simpler”

  • Fewer filesystem calls and fewer resolver calls after cache warms up.
  • More robust path construction (no string replacing an import specifier with an absolute path).
  • Cleaner match semantics (@src vs @srcWhatever).

One design question to consider (for the PR review)

This plugin makes @src resolve based on issuer location (which is the goal), but note it’s using request.context?.issuer. If webpack ever calls the resolver without an issuer (some edge cases/plugins), it silently falls back to default resolution (currently callback()), which might be desirable or might hide bugs. ([GitHub]1)
If you want stricter behavior, you could optionally error when @src is used without an issuer.

If you want, I can also sanity-check whether webpack already provides a hook/util that makes this pattern shorter (some enhanced-resolve helpers can reduce boilerplate), but the main “wins” here are really the matching + path join + caching.

yeah, let's see what's out there, you read the description of

+Each consuming app must define its own @src path mapping in its tsconfig.json. This is because:
+
+1. TypeScript uses the static path mapping in your tsconfig.json for IDE support (autocomplete, go-to-definition, type checking)
+2. Webpack uses a resolver plugin that dynamically finds the closest src directory relative to the importing file at build time
+
+This approach ensures that @src always resolves to your app's own src directory, even in complex project structures.

right?

Yes — I read that description, and it’s a good summary of what the PR is trying to accomplish:

  • TS needs a static mapping (paths in tsconfig) for editor + typechecking.
  • Webpack needs runtime resolution that can pick the closest src folder per importing app, not a single global alias.
  • The plugin exists because in a multi-app monorepo, a normal alias like @src -> /repo/src doesn’t work.

That said: there isn’t really an “off-the-shelf” webpack feature that does exactly “resolve an alias relative to the nearest ancestor src/ directory”. So a plugin is justified. But there are a couple things worth noting.


What’s already out there in webpack/enhanced-resolve

Webpack’s resolver system is built on enhanced-resolve, which is plugin-based by design. It explicitly expects custom plugins for nonstandard resolution rules. ([GitHub]1)

So conceptually, this approach is aligned with webpack’s architecture:

“core resolution functionality is implemented as individual plugins…”


Why resolve.alias can’t solve this

Webpack’s normal alias mechanism is static:

resolve: {
  alias: {
    "@src": path.resolve(__dirname, "src")
  }
}

This works only if there is one src root. ([webpack]2)

But your goal is:

  • app A: @srcapps/app-a/src
  • app B: @srcapps/app-b/src

depending on the issuer file.

Webpack aliases cannot vary dynamically per import site.

So a plugin really is the correct tool.


Is there a simpler built-in plugin?

Not really. Enhanced-resolve ships built-ins like:

  • AliasPlugin
  • ModulesInRootPlugin
  • RootsPlugin

…but they still operate with static configured paths, not “walk upward until you find src”.

DeepWiki’s alias resolution overview confirms aliasing is strictly name→path mapping, not contextual lookup. ([DeepWiki]3)

So: no existing resolver plugin provides “closest ancestor directory wins”.


The only realistic alternative approach

Use TypeScript project references / per-package builds

Instead of one dynamic alias, each consuming package could compile independently with its own tsconfig and webpack config. But that defeats the goal of a shared frontend-base build pipeline.

So the PR’s approach is pragmatic.


Where simplification is possible

Even if the plugin is necessary, it can be “more minimal” in a couple ways:

✅ Don’t hit the filesystem repeatedly

Webpack resolution is hot-path. statSync in a loop is expensive. Caching is the biggest win.

Enhanced-resolve even has explicit resolver caching options (unsafeCache, cacheWithContext). ([npm]4)

So a small Map cache is strongly recommended.


✅ Prefer path-safe joining instead of string replace

Because webpack runs on Windows too, absolute-path replacement can create mixed separators.


✅ Use an exact alias match

Webpack supports $ for exact alias matching. ([webpack]2)

So match @src$ or @src/…, not any prefix.


Review summary: is it as simple as possible?

Yes, given the requirement, a resolver plugin is basically unavoidable.

But it can be simplified/tightened in implementation:

  • better matching (^@src($|/))
  • cache src lookup
  • avoid double fs calls
  • safer path joining

Suggested PR comment (ready to drop in)

This plugin seems justified — webpack aliases are static and can’t resolve @src per consuming app. Enhanced-resolve is designed for custom resolution plugins.
That said, we could simplify + improve performance by:

  • matching @src more precisely (^@src($|/))
  • caching the closest src lookup per issuer directory (resolver runs a lot)
  • using path.join instead of string replace for cross-platform safety
    No built-in enhanced-resolve plugin seems to provide “closest ancestor src wins”, so this approach makes sense overall.

If you want, I can re-open the diff and write an even more exact patch suggestion against the actual code in PR #158.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants