DEV Community

Developer Open for Aqiron Security

Posted on AI-assisted

Separating a VS Code Extension from a TypeScript Core: Architecture Lessons from Aqiron Security

When I started building Aqiron Security, the natural approach was to keep the security logic inside the VS Code extension.

That works.

At least initially.

But as the project started accumulating scanner orchestration, finding normalization, correlation, project intelligence, RAG, reporting, and AI operations, I started running into a deeper architectural question:

Should the VS Code extension actually own the application's core runtime?

I decided the answer should be no.

Aqiron Security is an open-source application security project. The current product is a VS Code extension, but I wanted the security/runtime layer to have a much cleaner boundary from the client.

The result is a design where the extension acts as the client and starts a separate Node.js process containing the TypeScript core.

This article explains why I made that separation, how the current architecture works, and some of the trade-offs I have encountered.

The original problem

A VS Code extension has a lot of responsibilities already.

It deals with things like:

extension activation
commands
diagnostics
the VS Code API
webview communication
editor state
user settings
UI lifecycle

Security scanning introduces another class of responsibilities:

starting external tools
parsing scanner output
normalizing findings
correlating duplicate results
generating reports
managing long-running operations
cancellation
AI operations
workspace analysis

Putting all of that into one runtime makes the boundary blurry.

The code may still work, but over time the architecture starts answering questions like:

"Does this security service need VS Code?"

That is a dangerous dependency to create if the answer does not actually need to be yes.

The separation

The current Aqiron architecture looks roughly like this:

┌──────────────────────────────────────────────┐
│ VS Code Extension │
│ │
│ activation / commands / diagnostics │
│ React webview / settings / workspace UI │
│ ScanController / client-side services │
└──────────────────────┬───────────────────────┘

│ CoreClient
│ CoreProcessManager

│ newline-delimited JSON

┌──────────────────────────────────────────────┐
│ Aqiron Core Runtime │
│ Node.js + TypeScript │
│ │
│ protocol / cancellation / adapters │
│ scanning / analysis / correlation │
│ reports / RAG / AI operations │
└──────────────────────┬───────────────────────┘

┌────────────┴────────────┐
▼ ▼
Native Aqiron rules External scanners
Trivy / Semgrep
OSV-Scanner /
Betterleaks / MobSF
│ │
└────────────┬────────────┘

Unified findings

correlation + graph

reports

This is not a cloud architecture.

The current project is local-first. The extension starts the core process locally and communicates with it over standard input/output. The repository currently keeps packages/core private and bundles it into the extension rather than publishing it as a separate npm package.

That distinction is important.

The boundary exists today, but the eventual product packaging can evolve later.

Why a separate process?

There were several reasons.

1. Keep the core independent from VS Code

The first reason is architectural independence.

The core should not need to know that VS Code exists.

Ideally, security logic should be able to operate on concepts like:

workspace
scan
finding
project
report
AI request

rather than:

vscode.workspace
vscode.window
WebviewPanel
TextDocument
DiagnosticCollection

The extension is responsible for translating between the developer environment and the core.

That gives me a much cleaner dependency direction:

VS Code client

Core

rather than:

VS Code ↔ Security logic ↔ VS Code

2. Process isolation gives us a real boundary

Using a separate process also creates a runtime boundary.

The extension host and the security core no longer execute as one giant logical process.

That matters for long-running operations.

A scan might involve:

discover files

run multiple tools

parse results

normalize findings

correlate findings

build relationships

generate reports

That's a very different workload from handling an editor command or updating a sidebar.

With a separate core process, the extension can treat the security engine more like a service.

That makes lifecycle handling, restart behavior, and failure boundaries easier to reason about.

3. IPC forces us to define a contract

This was probably the most valuable part of the architecture.

Once the extension and core became separate processes, they couldn't casually call each other's internal functions anymore.

They needed a protocol.

The current protocol is intentionally simple:

stdin/stdout
+
newline-delimited JSON

A request looks conceptually like:

interface CoreRequestMessage {
  id: string;
  type: "request";
  method: string;
  params?: unknown;
}
Enter fullscreen mode Exit fullscreen mode

And a response:

interface CoreResponseMessage {
  id: string;
  type: "response";
  success: boolean;
  result?: unknown;
  error?: CoreProtocolError;
}
Enter fullscreen mode Exit fullscreen mode

There are also event messages for asynchronous pipeline updates:

interface CoreEventMessage {
  type: "event";
  event: string;
  requestId?: string;
  payload?: unknown;
}
Enter fullscreen mode Exit fullscreen mode

The actual protocol also has a versioned handshake.

For example, the current runtime exposes a protocol version and core version and can report compatibility states such as:

compatible
protocol-mismatch
extension-too-old
core-too-old
unsupported

That gives us an explicit compatibility boundary instead of relying on both sides silently assuming they agree.

Why newline-delimited JSON?

I deliberately didn't start with something complicated.

The current transport is essentially:

message 1\n
message 2\n
message 3\n

where each line contains one JSON message.

That gives us a few useful properties:

Easy to inspect

You can literally look at the communication stream.

Easy to debug

Malformed input can be identified and rejected.

No additional server required

The extension starts the process locally and communicates through stdio.

Language-neutral at the protocol level

The wire format is JSON rather than TypeScript-specific objects.

That last point matters.

The implementation is TypeScript, but the protocol doesn't fundamentally need to be.

Correlation IDs become important

Once requests and responses cross a process boundary, we need a way to know which response belongs to which request.

That's why requests carry an ID.

For example:

request id: abc123
method: scan.start

The response can return:

id: abc123
success: true

Without this, concurrent operations become painful to reason about.

The ID becomes the connection between:

request

core operation

response

and also gives us something useful for cancellation and pipeline events.

Cancellation is part of the architecture

Security operations shouldn't be treated as unstoppable functions.

Imagine starting a deep scan and then closing the workspace.

Or starting an AI analysis and then deciding you don't need it anymore.

The architecture therefore includes explicit cancellation operations.

The current core protocol exposes operations such as:

core.cancel
scan.cancel
ai.cancel

and the runtime propagates cancellation through the relevant cancellation sources and scanner context.

This is one of those details that seems unnecessary until you have a real long-running operation.

Then it becomes essential.

The extension should not know how scanning works

One of the goals of the boundary is to let the client ask for a scan without knowing the implementation details.

Conceptually:

await coreClient.startScan({
  workspaceRoot,
  mode: "deep",
  trusted: true
});
Enter fullscreen mode Exit fullscreen mode

The extension doesn't need to know:

which scanners are installed
how scanner output is parsed
how findings are normalized
how correlation works
how reports are generated

Those concerns belong to the core pipeline.

The current core pipeline can combine native Aqiron rules with optional external scanners, normalize results into UnifiedFinding[], then pass them through correlation/graph processing and report generation.

That separation is the main reason I like this architecture.

The finding model becomes the shared language

A scanner may produce one format.

Another scanner produces something completely different.

For example:

Scanner A
severity = HIGH
file = foo.dart
line = 41

while another might report:

Scanner B
level = error
path = foo.dart
startLine = 41

The core shouldn't force the rest of the system to understand every scanner's native format.

Instead, scanner-specific parsers convert the output into a common model.

Conceptually:

external scanner

scanner-specific parser

UnifiedFinding

correlation

report

This is one of the biggest advantages of having an application-level core rather than scattering scanner logic throughout the VS Code extension.

The architecture isn't completely finished

This is important because architecture diagrams can easily make an early project look more mature than it actually is.

Aqiron is currently version 0.0.1 and under active development.

There are still intentional limitations.

For example:

workspace operations currently require a Flutter workspace
external scanners are optional
the core is still bundled into the extension
quick file scans use a separate direct extension path
the project does not yet have independently published Core, CLI, or Desktop packages

So the current architecture is not:

`Aqiron Core npm package

VS Code
CLI
Desktop

Not yet.

It's closer to:

VS Code

internal Core process

bundled runtime`

That is an important distinction.

The architecture is being prepared for broader reuse without prematurely creating a bunch of packages that don't yet need to exist.

Why I didn't immediately split everything into repositories

This was another deliberate decision.

It would be easy to say:

aqiron-core
aqiron-security-vscode
aqiron-security-cli
aqiron-security-desktop

and create four repositories immediately.

But that would add operational complexity before the products existed.

You would now have to manage:

package publishing
version coordination
cross-repository changes
release synchronization
dependency management
contributor workflow across multiple repositories

The current repository gives me a cleaner intermediate step:

src/
packages/core/

with an explicit runtime boundary.

When multiple clients become real products, the repository structure can change.

Until then, the architecture can evolve without forcing the project to pay the cost of premature distribution.

What I like about this architecture

The biggest win isn't actually "using IPC."

The bigger win is making the boundary explicit.

The VS Code extension owns the developer environment.

The core owns security operations.

The protocol connects them.

That gives us a mental model like:

`Client responsibilities

UI
VS Code
commands
diagnostics
workspace interaction

      │
      │ protocol
      ▼
Enter fullscreen mode Exit fullscreen mode

Core responsibilities

scanning
normalization
correlation
RAG
AI operations
reports
`

That's much easier to reason about than a single giant extension runtime.

What I still need to watch

The architecture also introduces new problems.

A process boundary is not free.

Now we have to care about:

process startup time

restart behavior

malformed messages

protocol compatibility

stderr/stdout handling

partial failures

cancellation

shutdown

concurrent requests

serialization overhead

In other words:

We traded code coupling for process-boundary complexity.

I think that's a reasonable trade for Aqiron, but it isn't automatically the right choice for every VS Code extension.

If your project is a small command-based extension with a few hundred lines of logic, this architecture would probably be overkill.

For a growing security platform with multiple subsystems and long-running operations, the boundary becomes much more interesting.

The bigger goal

The long-term idea is not "make a complicated VS Code extension."

It's to make the security core reusable.

The future might eventually look like:

                Aqiron Core
               /     |      \
              /      |       \
             ↓       ↓        ↓

         VS Code    CLI     Desktop
Enter fullscreen mode Exit fullscreen mode

But I don't need to build all three clients today.

Right now, I'm using the VS Code extension as the first real client and using the Core boundary to keep the architecture ready for future evolution.

That's the part I'm most interested in getting right.

Final thoughts

The biggest lesson I've taken from this project is that architecture isn't about drawing the biggest possible diagram.

It's about deciding where responsibilities should stop.

For Aqiron, the important boundary became:

VS Code is the client.
The TypeScript runtime is the security engine.
IPC is the contract between them.

That doesn't mean the architecture is finished.

It means there is now a clear place to evolve it.

I'm still working through the trade-offs, so I'd be interested in hearing from people who have built:

VS Code extensions with external processes

TypeScript/Node developer tools

language-server-style architectures

security scanners

CLI + GUI products sharing a common runtime

How would you design this boundary differently?

Aqiron Security is open source and currently under active development.

Top comments (0)