wispwisp
Core concepts

How wisp works

The path from your TypeScript source to a deployed CloudFormation stack, one stage at a time.

wisp is a compiler, not a runtime framework. Nothing in wisp inspects your app while it's running: no dispatcher, no plugin registry, no dependency container. Everything is decided before deploy, by reading your source code.

That single decision explains most of wisp's behaviour — why routes must be top-level, why options have to be literals, and why the IAM policy it writes is as narrow as it is.

The pipeline

  src/**/*.ts

      │  1. Discover          find candidate source files

  TypeScript program

      │  2. Analyse           read the AST, collect resources

  Resources + handlers

      │  3. Cross-reference   link handlers to what they touch

  ResourceGraph

      │  4. Validate          duplicate names, scope rules

  Validated graph

      │  5. Emit              CloudFormation + derived IAM

  Templates

      │  6. Gate              always-on cost scan

  .wisp/cloudformation.json  →  your AWS account

Steps 1 through 4 live in @vorynza/wisp-compiler. Step 5 is @vorynza/wisp-emit-aws. Step 6 is the always-on denylist, and it's the last thing that runs before anything reaches AWS.

1. Discover

wisp walks src/ and collects the TypeScript files that could declare resources. There's no manifest to keep in sync and no decorator to register — the file layout is the registration.

2. Analyse

wisp builds a real TypeScript program and reads the syntax tree. When it finds new Store("orders", {...}) or api.post("/orders", {}, handler), it records a resource: its name, its options, and the source location it came from.

This step never executes your code. That's why the compiler insists on values it can read statically:

// wisp can read this.
export const orders = new Store("orders", {
  partitionKey: { name: "id", type: "S" },
});

// wisp cannot read this — the options only exist once the code runs.
const options = loadOptionsFromSomewhere();
export const orders = new Store("orders", options);

The second form is rejected with WISP-CONFIG-001 rather than silently producing a half-configured table.

3. Cross-reference

Now wisp connects handlers to the resources they use. It resolves the symbol behind each call — not the text — so renaming an import doesn't confuse it:

import { orders as orderTable } from "../stores/orders.ts";

export const createOrder = api.post("/orders", {}, async ({ body }) => {
  await orderTable.put(body); // still recognised as the "orders" Store
});

Each connection becomes an edge in the graph, carrying the action performed: read, write, delete, send, receive, or invoke. Those edges are what the IAM derivation reads later, which is why the permissions wisp writes match what your code actually does. See IAM derivation.

4. Validate

Before emitting anything, wisp checks the graph as a whole: two resources sharing a name, a primitive constructed somewhere it can't be statically found, a queue with more than one consumer. These are the WISP-DUP, WISP-SCOPE, and WISP-CONFIG diagnostics.

5. Emit

The validated graph becomes CloudFormation. Each function gets its own role, built from its own edges and nothing else. If the app is large enough, the template is split into nested stacks.

Your escape hatches are applied here too — first property overrides, then raw resources, then the whole-template transform.

6. Gate

Finally wisp scans the finished template for resources that cost money while idle. This runs on the output of the escape hatches, deliberately: a transform() cannot be used to slip an always-on resource past the check.

If the scan is clean, the template is written to .wisp/cloudformation.json and deployed. If it isn't, the deploy stops and prints what it found.

Seeing it for yourself

Two commands expose the middle of the pipeline:

wisp graph    # the ResourceGraph from step 3
wisp synth    # the CloudFormation from step 5, written to .wisp/

Neither touches AWS. wisp synth in particular is worth running before your first deploy — the output is plain, readable JSON, and it's exactly what would have been sent.

Why a compiler

Reading code instead of running it costs you some flexibility: options must be literals, and resources must be declared where they can be found. In exchange:

  • Permissions are exact. wisp knows every resource each handler touches, so it never has to guess and grant something broad.
  • Cost is knowable before deploy. The template is complete before anything is created, so it can be scanned and priced.
  • There's no runtime to pay for. Nothing of wisp's own dispatches your requests, so nothing of wisp's own shows up in your cold start.

On this page