Skip to content

fix: report a usable error when @angular/language-server is missing - #100

Closed
nntndfrk wants to merge 2 commits into
nathansbradshaw:mainfrom
nntndfrk:fix/language-server-resolution-diagnostics
Closed

fix: report a usable error when @angular/language-server is missing#100
nntndfrk wants to merge 2 commits into
nathansbradshaw:mainfrom
nntndfrk:fix/language-server-resolution-diagnostics

Conversation

@nntndfrk

Copy link
Copy Markdown

Fixes #99.

Problem

When @angular/language-server is not in the project, node stops before the server starts. Zed shows a MODULE_NOT_FOUND stack trace. The trace does not name this extension and does not name the package to install, so the reader cannot act on it. #97 is the same report, answered by hand in the comments.

A filesystem check in the extension is not an option. #95 showed that the Zed worktree snapshot excludes node_modules, so the check gives false negatives. The current code documents this and is right to do so.

Approach

Route the invocation through a small node --eval preamble, then pass the entry point and the existing flags after --.

The preamble leaves process.argv byte for byte identical to a plain node <entry> call, so the server parses its own flags unchanged.

node --eval <preamble> -- <server>/index.js --stdio --tsProbeLocations ... 

It adds two behaviours:

  1. Fallback to node's resolver. If the literal path does not resolve, try require.resolve('@angular/language-server', { paths: [root] }). Node walks up the directory tree, so a workspace that hoists the package to a parent node_modules now works with no angular_language_server_path setting.
  2. A usable error. If both fail, print the locations tried, the install command, and the major version rule, then exit 1.

All resolution stays inside node. The extension performs no filesystem access, so #95 cannot regress.

Before

node:internal/modules/cjs/loader:1503
  throw err;
  ^
Error: Cannot find module '/Users/me/proj/node_modules/@angular/language-server/index.js'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1500:15)
    ... 7 more frames
  code: 'MODULE_NOT_FOUND',

After

The Angular extension cannot find the @angular/language-server package.

It looked in these locations:
  /Users/me/proj/node_modules/@angular/language-server/index.js
  node module resolution from /Users/me/proj

The extension does not download a language server. Install the package
in your project:

  npm install --save-dev @angular/language-server typescript

The major version of @angular/language-server must be equal to the major
version of Angular in your project.

If the package is in a different location, set the location in your Zed
settings with this option:
  lsp.angular.initialization_options.angular_language_server_path

Testing

Built with cargo build --release --target wasm32-wasip1. cargo fmt --check is clean.

The shipped preamble was extracted from the built source and driven with a real LSP initialize request against @angular/language-server 21.1.5 on node v24.16.0:

Scenario Result
Package present at the literal path Server starts, initialize responds with capabilities
Literal path wrong, package hoisted at the worktree root Server starts, initialize responds with capabilities
Package genuinely absent Message above, exit 1, no stack trace

Scenario 1 confirms no behaviour change on the existing happy path. Scenario 2 is new. parseCommandLine in @angular/language-server scans argv with indexOf, and the preamble preserves argv positions anyway, so flag parsing is unaffected.

Notes

  • The two behaviours are independent. If you want only the diagnostic and not the resolver fallback, I am happy to drop the fallback branch.
  • I did not touch extension.toml or Cargo.toml versions, since releases look like separate commits in this repo. Tell me if you want the bump included here.

When `@angular/language-server` is absent, node fails before the server
starts and Zed shows a bare `MODULE_NOT_FOUND` stack trace. The trace
names neither this extension nor the package the user has to install, so
the reader has no way to know what to do next. See nathansbradshaw#97.

Route the invocation through a small `node --eval` preamble. The preamble
keeps `process.argv` identical to a plain `node <entry>` call, so the
server parses its flags unchanged, and it adds two behaviours:

- If the literal path does not resolve, fall back to node's own resolver
  from the worktree root. Node walks up the directory tree, so a
  workspace that hoists the package to a parent `node_modules` now works
  without `angular_language_server_path`.
- If both attempts fail, print the locations that were tried, the install
  command, and the version rule, then exit.

Resolution stays in node. There is no filesystem check in the extension,
so the false negatives from nathansbradshaw#95 do not come back.
@jpike88

jpike88 commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

seems ok at a glance. Though I don't know if @nathansbradshaw may have opinions around the use of --eval here

@nathansbradshaw

Copy link
Copy Markdown
Owner

I do worry that the use of --eval here could unintentionally compromise someone at some point.

Addresses the review concern that building a `--eval` script could
compromise someone by accident later on.

The worktree root now reaches the preamble through the
ZED_ANGULAR_WORKTREE_ROOT environment variable instead of being inlined,
so LOADER_SCRIPT is a compile-time constant. Nothing is templated,
formatted, or concatenated into the string node evaluates, which removes
the escaping step and the placeholder that a later change could have
interpolated an unescaped value into. Any inherited value of the variable
is dropped so the shell cannot redirect the lookup.

Also load the entry point with `Module._load(entry, null, true)` rather
than `require(entry)`. Under `--eval` a plain require leaves
`require.main` undefined, so an entry point guarded by
`require.main === module` would load and silently do nothing. The current
@angular/language-server has no such guard, but that guard is the usual
shape for a CLI entry point and the failure would look like a Zed bug.
@nntndfrk

Copy link
Copy Markdown
Author

@nathansbradshaw Fair concern. I've changed it so there's nothing to escape.

The worktree root is no longer inlined into the script. It's passed as an environment variable instead, so the script is now a static constant: fixed at compile time, never built up from anything at runtime. The placeholder and the escaping step are both gone, so there's no place where a future change could slip an unescaped value in.

I also fixed something I noticed while checking. Under --eval, require() leaves require.main undefined, where a normal node index.js sets it. The current @angular/language-server doesn't check require.main, so the original worked, but that check is common in CLI entry points and if one ever appeared the server would start and silently do nothing. Module._load(entry, null, true) keeps this identical to a normal launch.

Retested every scenario from the PR description plus the new ones against @angular/language-server 22.1.4 on node 24: happy path unchanged, hoisted package found, missing package gives the diagnostic, and a hostile path in the environment variable can't execute anything.

Worth noting: vscode-languageserver, bundled inside @angular/language-server, already uses node -e for exactly this purpose. And it does it the same way this PR now does: the script is a fixed string, and the variable part (the module name to resolve) is passed in at runtime rather than baked into the source. So this isn't a new pattern here, and your instinct about how to do it safely matches theirs.

@jpike88

jpike88 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Can we please cut out the AI slop. Thanks.

@jpike88

jpike88 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

I will go ahead and close this because of two thoughts:

  • is this just AI overengineering, which really indicates that the user did not consult the readme of this extension. If this is a case of the readme not being clear enough to get up and running, then we can take the discussion there.
  • a chunk of javascript (presumably for NodeJS) inlined into a Zed rust extension, overloading an environment variable to thread in via --eval... it's hacky and increases complexity and technical debt to an extent that a simple extension like this shouldn't have to bear.

So to conclude, if the README isn't doing it's job, let's improve the README.

@jpike88 jpike88 closed this Aug 27, 2026
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.

Missing @angular/language-server gives a node stack trace instead of a usable error

3 participants