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

> Source: <https://dev.to/aqiron-security/separating-a-vs-code-extension-from-a-typescript-core-architecture-lessons-from-aqiron-security-4mc5>
> Published: 2026-09-23 14:51:53+00:00

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 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.

There were several reasons.

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

`VS Code ↔ Security logic ↔ VS Code`

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.

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

And a response:

```
interface CoreResponseMessage {
  id: string;
  type: "response";
  success: boolean;
  result?: unknown;
  error?: CoreProtocolError;
}
```

There are also event messages for asynchronous pipeline updates:

```
interface CoreEventMessage {
  type: "event";
  event: string;
  requestId?: string;
  payload?: unknown;
}
```

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.

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:

You can literally look at the communication stream.

Malformed input can be identified and rejected.

The extension starts the process locally and communicates through stdio.

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.

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.

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.

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

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.

A scanner may produce one format.

Another scanner produces something completely different.

`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.

`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.

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.

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.

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
      ▼
```

Core responsibilities

    ↓

scanning

normalization

correlation

RAG

AI operations

reports

`

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

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

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.

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

**Aqiron Security is open source and currently under active development.**
