Skip to main content
Alpha

The NetScript architecture

NetScript is a set of Deno-native framework packages, published on JSR and composed into a running backend by first-party plugins, orchestrated locally by Aspire. This essay answers one question: when you build an app on NetScript, what are the layers you actually work with, what runs where, and which way do the dependencies point?

A NetScript workspace as four stacked layers: typed contracts at the base, packages and plugins composed on top, the host wiring plugins through generated registries, and the runtime executing services and background processors — all orchestrated by Aspire.
The four layers an app author works with: typed contracts, packages and plugins, the host that assembles them, and the runtime that executes services and background processors.

Read this for orientation, not step-by-step work. The [tutorials]( Tutorials ) and [how-to guides]( How-to guides ) are the practical paths; the glossary defines every term of art; and each layer below routes you onward to the capability hub or reference unit that elaborates it.

The thesis: the published surface is the product

NetScript treats publishing as the whole point. A package's mod.ts is not an implementation detail that happens to be exported — it is the contract the framework makes with you, the caller. The shape you see through deno doc, the JSR doc score, and the type checker is the surface everything else serves.

Two consequences explain most of the design decisions you will meet:

  1. Types are designed before implementation. The public types and the README come first; the code that satisfies them comes second. If deno doc <package> does not read end-to-end as a manual, the surface is considered broken, regardless of how the internals work.
  2. Simple is preferred over easy at every boundary. Approachability comes from a small, predictable surface rather than hidden magic. The common 80% case is one chained call; advanced cases unfold one method deeper rather than appearing as required configuration on the entry point.

The deeper rationale for this contract-first stance is the subject of [Contracts & type flow]( Contracts & type flow ).

Four layers, one direction of dependency

The single most useful thing to hold in your head is the direction of the arrows. Every layer below depends only on the layers beneath it, and nothing ever edits another boundary's code. Build that picture once and the rest of the framework stops surprising you.

1. Typed contracts — the base, with no dependencies

At the bottom sits the contract layer: schemas and types that describe what a service accepts and returns, with essentially no runtime and no dependencies of their own. A contract is a plain object built from @orpc/contract and zod schemas; the same object is imported by the service that implements it and by the client that calls it, so the two can never drift. Because contracts depend on nothing, everything above can depend on them freely.

This is why "change the shape" is a type error you see at compile time rather than a 500 you discover in production. The contract machinery — @orpc/contract, zod, and implement() — is the subject of [Contracts & type flow]( Contracts & type flow ); the generated reference is [@netscript/contracts]( contracts ).

2. Packages and plugins — composing the substrate

Above the contracts sit two kinds of building block:

  • Platform packages (the @netscript/* kernel) know nothing about any one capability. They are the shared substrate: [config]( config ), [service]( service ), [database]( database ), [queue]( queue ), [kv]( kv ), [telemetry]( telemetry ), and the rest. A package wraps one concern behind a small surface and never reaches across into another package's internals.
  • Plugins turn that substrate into a capability — workers, sagas, triggers, auth, streams. A plugin composes packages; it does not redefine them. It declares its capability by registering named contributions (a service, a background processor, a Prisma schema fragment, a stream topic) and leaves the wiring to the host.

The rule that keeps this clean: a plugin depends on packages, packages depend on contracts, and the arrow never reverses. A workers plugin imports the contract types from its sibling [@netscript/plugin-workers-core]( workers ) package rather than inventing its own; an auth plugin composes the auth seam rather than embedding a provider. The mechanics — public plugin versus core package, manifests, contributions, and the generated registry — live in [The plugin system]( The plugin system ).

(You may see the word archetype in the doctrine and tooling. That is an internal classification the framework uses for its own quality gates — as an app author you meet it only indirectly, through the shape of a scaffolded plugin. See [The plugin system]( The plugin system ) if you are curious; it is not something you configure.)

3. The host — assembling plugins through generated registries

A workspace is more than a pile of plugins; something has to assemble them. That is the host's job. When you run the scaffold's generate step, NetScript reads the plugin list from netscript.config.ts, validates each plugin's contributions against fixed extension axes, and writes generated registry files that wire the contributions together. The host then loads those registries at startup.

The discipline here is the same as everywhere else: the host reads plugin manifests and generates wiring; it never asks you to hand-edit one plugin to make room for another. Two enabled plugins coexist because the host merged their registrations, not because either was patched. This is why enabling a capability is a config line plus a regenerate, and why the generated files are reproducible output you do not author by hand.

4. The runtime — executing services and background processors

At the top, the host's assembled workspace runs. NetScript is not one process: it is a small constellation of single-purpose services, each owned by a plugin and each speaking a contract, brought up together by Aspire. Two kinds of thing execute here:

  • Services answer requests. Most are [oRPC services]( Services & contracts ) mounted at /api/rpc/*; trigger ingress is the exception, exposing raw Hono routes because webhooks are webhook-shaped, not contract-shaped.
  • Background processors do work outside the request path: the worker job runtime, the durable saga runtime, schedulers, and file watchers. They own state and lifecycle, persist through a store, and shut down cleanly through a { stop() } handle.

The topology below is the canonical shape of a fully-loaded workspace, with every first-party plugin enabled.

                              ┌───────────────────────────────────┐
                              │            Aspire AppHost          │
                              │   dashboard  http://localhost:18888│
                              │  provisions Postgres + Redis (KV)  │
                              └───────────────┬───────────────────┘
                                              │ wires env, ports, resources
            ┌─────────────────────────────────┼─────────────────────────────────┐
            │                                  │                                 │
   ┌────────▼────────┐                ┌────────▼─────────────────────────┐
   │  example service │                │   PLUGIN SERVICES (oRPC / Hono)  │
   │   users :3001    │                ├──────────────────────────────────┤
   │  /api/rpc/*      │                │  workers  :8091   (jobs/tasks)   │
   │  oRPC contract   │                │  sagas    :8092   (durable flows)│
   └────────┬─────────┘                │  triggers :8093   (raw Hono in)  │
            │                          │  auth     :8094   (oRPC /api/rpc)│
            │ contracts                └────────┬─────────────────────────┘
            │ (@orpc/contract + zod)            │
            │                          ┌────────▼─────────┐
   ┌────────▼──────────────────────────┴──────────────────┴──────────────────────┐
   │                  PACKAGES  (@netscript/* platform substrate)                  │
   │  config · runtime-config · service · contracts · sdk · plugin · database      │
   │  queue · kv · cron · logger · telemetry · aspire                              │
   └────────┬──────────────────────────────────────────────────────────┬──────────┘
            │ produces durable change-data events                       │
   ┌────────▼─────────┐                                      ┌──────────▼─────────┐
   │  streams  :4437   │  durable-stream service             │  Postgres / Redis   │
   │  HTTP / SSE       │  workers · auth · sagas mirror here  │  (relational + KV)  │
   └───────────────────┘                                      └────────────────────┘

Read the picture in three bands. Aspire sits on top as the local orchestrator: cd aspire && aspire start brings up Postgres and Redis (the default cache backend; garnet or deno-kv are selectable via --cache-backend) and starts every service before any netscript db command runs, with traces, logs, and health landing in the dashboard at http://localhost:18888. Plugin services sit in the middle, each owning exactly one service on a fixed port; your own scaffolded users service sits alongside them, speaking the same contract machinery. The packages sit underneath as the shared substrate every plugin composes against. See [Orchestration with Aspire]( Orchestration with Aspire ) for the control-plane detail.

Workspace ports and surfaces
SurfacePortProtocolOwner
Aspire dashboard :18888 HTTP (UI) Aspire AppHost
Example service (users) :3001 oRPC over /api/rpc/* your service
Workers :8091 oRPC @netscript/plugin-workers
Sagas :8092 oRPC @netscript/plugin-sagas
Triggers :8093 raw Hono routes @netscript/plugin-triggers
Auth :8094 oRPC over /api/rpc/v1/auth/* @netscript/plugin-auth
Streams :4437 durable-stream HTTP / SSE @netscript/plugin-streams

A subtlety worth fixing early: triggers expose raw Hono routes, not oRPC, because trigger ingress is webhook-shaped rather than contract-shaped. Every other plugin service is an oRPC service, and service RPC is mounted at /api/rpc/* (not /rpc). Those two facts trip people up more than any other detail here.

The pure-backend seam: a port with interchangeable adapters

There is one recurring pattern that, once you see it, you will recognize across the whole framework: the pure-backend seam. It is how NetScript keeps a capability open to several implementations without leaking any of them into the contract.

The shape is always the same:

  1. A package defines a port — a pure interface, no IO — that says what the capability must be able to do.
  2. Adapter code implements that port against a concrete technology. Adapters are pure backends: they depend on the port, never the reverse, and add no public surface of their own beyond the factory you call.
  3. A plugin composes exactly one active adapter, selected by configuration, and exposes it as a running service.

Auth is the most visible instance: a core seam defines an AuthBackendPort, three pure adapters implement it (a default interactive KV-OAuth backend, plus WorkOS and Better-Auth), and the auth plugin composes the one backend named by NETSCRIPT_AUTH_BACKEND. A capability a given backend cannot honor fails loud through a typed AuthBackendOperationUnsupportedError rather than silently doing nothing. The full treatment — the port, the three backends, and the per-backend capability matrix — lives in [The auth model]( Auth model ); the capability hub is [Authentication]( Authentication ).

This is why "swap the backend" is a configuration change in NetScript and not a rewrite: the contract is the port, and adapters are interchangeable behind it.

Configuration records intent before runtime wiring

NetScript keeps authored intent separate from the concrete runtime wiring that makes a workspace run. The project-level intent lives in netscript.config.ts, authored with [defineConfig]( config ) or defineConfigAsync. That object says what the project is — its name, paths, enabled plugins, services, apps, database declarations, saga groups, trigger groups, and runtime-config output paths. At startup, loadConfig and initConfig resolve that authored form into a validated NetScriptConfig, and getConfig is the synchronous read path after initialization.

The split matters because the host needs both halves: netscript.config.ts carries workspace intent such as paths and the plugin list (./plugins/workers/mod.ts, ./plugins/sagas/mod.ts, and the rest), while the operational database and resource details are carried by appsettings.json and the Aspire AppHost. Plugin packages can contribute partial config fragments through @netscript/config/merge, but those fragments merge into the already-validated project shape rather than replacing project identity. Runtime overrides are a third layer exposed by [@netscript/runtime-config]( runtime-config ) for topics such as jobs, sagas, triggers, features, and tasks.

Think of it as three nested layers: netscript.config.ts declares project intent, plugin config contributions extend that intent, and appsettings/Aspire/runtime-config materialize the environment-specific wiring. This is also where the capability switches live — the saga durable store (NETSCRIPT_SAGA_STORE=kv|prisma), the queue provider, and the active auth backend (NETSCRIPT_AUTH_BACKEND) are all selected here rather than in code. It is also why the database docs note a scaffold reality: databases.config can be empty in netscript.config.ts while the chosen engine — Postgres by default, or mysql / mssql / sqlite selected with --db at scaffold time — is still provisioned by Aspire (sqlite is file-backed, so it has no Aspire container resource) and described in appsettings.json. See [Runtime configuration]( Runtime configuration ).

Naming is part of the contract

Entry points follow fixed verbs, so you can predict behavior from the name alone:

  • defineX(...) returns a frozen definition and does no runtime work.
  • createX(...) constructs a runtime object that owns state and IO.
  • startX(...) constructs and starts a runtime, returning a { stop() } handle.
  • useX(...) is a hook-style accessor inside a request, render, or handler scope.
  • withX(...) is a builder method that returns a new builder and never mutates.

When a mod.ts would re-export more than roughly twenty symbols, the surface splits into subpath exports (for example @netscript/logger/middleware and @netscript/logger/orpc) to keep bundles lean and isolate adapter-specific code from production exports.

Durable behavior is modeled as state machines

For the background processors that own durable flows, NetScript models them as explicit state machines rather than generic event-handler ladders. [Durable sagas]( Durable sagas ) are the clearest example: a saga is named phases with explicit correlation, persistence, and compensation, declared through a builder.

// plugins/sagas/user-registration.saga.ts
import { defineSaga } from "@netscript/plugin-sagas-core";

type State = Readonly<{ status: string }>;

const saga = defineSaga("user-registration")
  .state<State>({ status: "started" })
  .on("UserRegistered", (s, _message, _context) => {
    s.state = { status: "welcoming" };
  })
  .on("WelcomeEmailSent", (s, _message, _context) => {
    s.state = { status: "complete" };
  })
  .build();

Failure handling is just as explicit. Handlers throw rich errors; a supervisor — not a scatter of defensive try/catch inside the handler — decides whether to restart or escalate, and owns the telemetry for that decision. The durable runtime persists through a selectable kv or prisma store. The full flow is covered in [The durability model]( The durability model ).

Observability is built into the substrate

Because every plugin composes the same packages, observability is wired once and inherited everywhere. aspire starts an OTLP collector, and the worker job path — dispatch, execution, scheduler, and subprocess continuation — emits real OpenTelemetry spans automatically, so job traces appear in the Aspire dashboard without any handler code.

The full picture is in [Observability]( Observability ).

The publish gate is the architecture gate

Tests are treated as fitness functions, and the publish gate is where the architecture is enforced rather than merely described. deno publish --dry-run, deno doc, the workspace type checks, the semantic tests, and an export/docs audit are not optional steps — they are the mechanism by which every boundary rule above survives contact with real code. A surface that does not pass them is broken by definition, which is precisely why the published surface can be trusted as the product.

Where to go next

  • The contract machinery: how types flow from a contract to a client in [Contracts & type flow]( Contracts & type flow ).
  • The plugin mechanics: public-versus-core split, manifests, and registries in [The plugin system]( The plugin system ).
  • The auth seam in depth: the port, the three backends, and the capability matrix in [The auth model]( Auth model ).
  • Durable behavior: how sagas and workers persist and recover in [The durability model]( The durability model ).
  • The local control plane: how Aspire provisions everything in [Orchestration with Aspire]( Orchestration with Aspire ).
  • Exact symbols: open any package's reference unit.
  • Hands-on: the [tutorials]( Tutorials ) and [how-to guides]( How-to guides ).