Projects in AlexScript

AlexScript is a language designed for actual programs, not just toy examples — and these three projects exist to prove that. Each of them is a few-thousand-line real-world application written entirely in AlexScript, exercising the language’s OOP, modules, async, exception handling, and standard library. They’re useful in their own right (an interpreter, a web framework, an HTTP client) and they double as worked examples of how AlexScript scales beyond the tutorial.

Read the source if you’re curious how to organize a multi-file AlexScript project — they all use modules, separate files per concern, and follow consistent naming conventions. Each project links to its repository at the end of its section.

BASIC — an Altair-style BASIC interpreter

asbasic is a complete BASIC interpreter implemented in AlexScript — line numbers, GOTO, GOSUB/RETURN, FOR/NEXT, DEF FN, DATA/READ/RESTORE, the works. You start it from a terminal, get a > prompt, type line-numbered programs, and run them with RUN. Programs save to and load from text files. The vibe is straight out of an Altair manual.

The project is also a useful demonstration that AlexScript handles the kind of project people normally reach for Ruby or Python for: tokenizer, parser, interpreter, and an interactive shell, all in one tidy module tree.

What it supports

The full range of classic BASIC statements:

Built-in functions cover what you’d expect: ABS, SGN, SQR, INT, LOG, EXP, the trig family, RND for random numbers, LEN, STR$, CHR$, VAL, LEFT$/RIGHT$/MID$ for strings.

REPL commands

Command Effect
RUN execute the stored program
LIST display the stored program
NEW clear the stored program
CLEAR clear the screen
SAVE "file" save program to disk
LOAD "file" load program from disk
HELP show the command list
QUIT exit

A line that starts with a number is stored in the program buffer; typing just the number deletes that line; anything else is treated as an immediate command.

Running a program

Start the interpreter:

alexscript basic.as
AlexScript Altair Basic 0.0.1
Type 'HELP' for available commands
>

Type a program (note the line numbers — that’s how BASIC stores it):

> 10 PRINT "Calculate factorial"
> 20 INPUT "Enter a number: "; N
> 30 LET FACT = 1
> 40 FOR I = 1 TO N
> 50   LET FACT = FACT * I
> 60 NEXT I
> 70 PRINT N; "! ="; FACT
> RUN
Calculate factorial
Enter a number: ? 5
5 ! = 120

DATA and READ work as you’d expect from classic BASIC:

10 DATA "Alice", 30, "Bob", 25
20 READ NAME$, AGE
30 PRINT NAME$, AGE
40 READ NAME$, AGE
50 PRINT NAME$, AGE
60 RESTORE
70 READ NAME$
80 PRINT NAME$

Running this prints Alice’s data, then Bob’s, then Alice’s again after RESTORE rewinds the data pointer.

User-defined functions compose:

10 DEF FN SQUARE(X) = X * X
20 DEF FN HYP(A, B) = SQR(FN SQUARE(A) + FN SQUARE(B))
30 PRINT FN HYP(3, 4)

Output: 5.

Architecture notes

The codebase is split across six AlexScript files: basic.as (entry point), repl.as (interactive shell), interpreter.as (statement execution), parser.as (expression parsing and built-in function dispatch), lexer.as (tokenizer), and runtime.as (shared interpreter state).

A few specific decisions worth noting:

Repository

github.com/N3BCKN/asbasic

Zubr — a micro web framework

zubr is a Sinatra-style HTTP framework for AlexScript. You construct a server, register routes, optionally add middleware, and call start(). It handles HTTP/1.1 parsing, routing, request body decoding, response building, and the connection lifecycle.

Out of the box it covers the things every real web service needs — JSON APIs, static files, sessions, cookies, CORS, rate limiting — without making you reach for a single external dependency. It only uses AlexScript’s bundled standard libraries (socket, json, czas, digest, securerandom, plik, http).

What it supports

The headline features:

Hello world

The minimal Zubr server:

import("../lib/zubr")

niech serwer = Zubr::Serwer.nowy(8080)

serwer.get("/", fn(zad) {
  zwroc Zubr::Odpowiedz.tekst(200, "Hello, World!\n")
})

serwer.start()

That’s the whole file. Running it starts an HTTP server on port 8080 that responds to GET / with a plain-text greeting.

Routing and JSON responses

Parametric segments use :name syntax — the captured value lives in zad.parametry():

serwer.get("/json", fn(zad) {
  zwroc Zubr::Odpowiedz.json(200, {
    "message": "Hello",
    "timestamp": Czas.stempel()
  })
})

serwer.get("/users/:id", fn(zad) {
  zwroc Zubr::Odpowiedz.json(200, {
    "id": zad.parametry()["id"]
  })
})

serwer.post("/echo", fn(zad) {
  niech dane = zad.dane()
  jesli dane == nic to dane = {}
  zwroc Zubr::Odpowiedz.json(200, dane)
})

A handler is a function that takes a request (zad, short for “zadanie”) and returns a response (Odpowiedz). The Odpowiedz class has factory methods for every common case — tekst, json, html, plik (file), przekieruj (redirect), brak_zawartosci (204 No Content). Every Zubr handler returns one.

Middleware

Middleware is registered with serwer.middleware(...). The standard middleware classes are bundled:

serwer.middleware(Zubr::Middleware::Log::standardowy())
serwer.middleware(Zubr::Middleware::CORS::pozwol("*"))
serwer.middleware(Zubr::Middleware::RateLimit::na_ip(60, 60))
serwer.middleware(Zubr::Middleware::Sesja::standardowa("change-me-please-make-this-long"))

Order matters — the first registered runs outermost, the last runs innermost (closest to the handler). The session middleware should usually be last since handlers want to read it via zad.sesja().

The session API is straightforward:

serwer.post("/login", fn(zad) {
  zad.sesja().ustaw("user_id", 42)
  zwroc Zubr::Odpowiedz.tekst(200, "Logged in\n")
})

serwer.get("/me", fn(zad) {
  niech uid = zad.sesja().pobierz("user_id")
  jesli uid == nic to zwroc Zubr::Odpowiedz.tekst(401, "Login required\n")
  zwroc Zubr::Odpowiedz.tekst(200, "User " + uid.napis() + "\n")
})

Cookies are HMAC-signed with the secret you pass to the middleware. Tampering server-side is impossible without knowing the key.

A real example: a notes API

For a sense of how this scales to actual applications, here’s a slice from the notes example app — a route handler module for CRUD operations on user-scoped notes:

modul App {
  modul Trasy {
    modul Notatki {
      funkcja zarejestruj(serwer) {
        serwer.get("/api/notatki",
          App::AppMiddleware::wymagaj_loginu(fn(zad) {
            zwroc obsluz_liste(zad)
          })
        )

        serwer.post("/api/notatki",
          App::AppMiddleware::wymagaj_loginu(fn(zad) {
            zwroc obsluz_utworz(zad)
          })
        )

        # ... PUT, DELETE, GET-by-id register similarly
      }

      prywatna funkcja obsluz_liste(zad) {
        niech uid = zad.sesja().pobierz("uzytkownik_id")
        niech mag = App::Modele::magazyn()
        niech notatki = mag.notatki_uzytkownika(uid, "", "")
        # ... build response object
        zwroc Zubr::Odpowiedz.json(200, { "notatki": tablica })
      }
    }
  }
}

This style — a module per route group, a zarejestruj(serwer) function that hooks the routes onto the server, private handler functions for each verb — scales well and stays readable as the application grows. The full notes example has separate modules for routes, models, validators, and middleware.

Performance

Benchmarks from a 2024 MacBook Pro:

Workload Throughput p50 p99
Hello world, 10 connections 2,150 req/s 0.97 ms 354 ms
Hello world, 50 connections 1,590 req/s 0.95 ms 1.39 s
Routing + 3 middleware 1,540 req/s 1.2 ms 14 ms
Static file 50 MB, 10 concurrent 1.97 GB/s 282 ms
Static file 50 MB, sustained constant ~100 MB RSS

Throughput is bounded primarily by the AlexScript interpreter and Ruby’s GVL, but for the workloads people actually deploy AlexScript on — internal APIs, prototypes, small services — there’s plenty of headroom.

Architecture

Zubr is split across lib/ (core framework: parser, router, response builder, connection handler, the Serwer class itself) and middleware/ (the bundled middleware modules). The server uses thread-per-connection dispatch — each accepted TCP connection runs in its own Ruby thread, parses the request, runs it through the middleware chain, and writes the response.

This is simpler and more reliable than a fiber-based async approach for the current state of AlexScript’s runtime, at the cost of bounding throughput to what Ruby’s GVL allows. It works well in practice — the included notes demo app runs reliably under sustained load with multi-user data isolation.

Status: beta. The API is stable but the implementation is young.

Repository

github.com/N3BCKN/zubr

Posel — a high-level HTTP client

posel is to the native Http library what Axios is to fetch, or HTTParty to Ruby’s Net::HTTP: a higher-level wrapper that adds the things real applications actually need — configurable client instances with default headers, request/response interceptors, a typed exception hierarchy, and a parallel-friendly async API.

For one-off requests you can call the module facade directly. For anything more involved, you build a Posel::Klient configured with your base URL, default headers, timeout, and so on.

import("./posel/posel")

# One-off request
niech user = Posel::get_json("https://jsonplaceholder.typicode.com/users/1")
pokazl user["name"]

# Configured client
niech api = Posel::Klient.nowy({
  "bazowy_url": "https://api.example.com/v1",
  "naglowki": { "Authorization": "Bearer xyz123" },
  "limit_czasu": 10
})

niech users = api.get_json("/users")

What it supports

Configuration

A Posel::Klient is built from a config hash:

Key Type Default Description
bazowy_url string nic Prepended to relative paths. Full URLs in calls override it.
naglowki hash {} Default headers. Merged with per-call headers (per-call wins).
parametry hash {} Default query params. Merged with per-call params.
limit_czasu integer 30 Timeout in seconds.
max_przekierowan integer 5 Maximum redirects to follow.
rzucaj_bledy bool prawda If true, raises typed exceptions on 4xx/5xx.

Every method also accepts an optional final opcje hash that overrides the client config for that one call:

api.get("/users", {
  "naglowki": { "X-Request-ID": "abc" },
  "parametry": { "limit": "10", "offset": "0" },
  "limit_czasu": 5,
  "rzucaj_bledy": falsz
})

The response object

Non-JSON methods return a Posel::Odpowiedz:

niech odp = api.get("/users/1")

odp.status()              # 200
odp.cialo()               # raw response body
odp.json()                # parsed JSON, lazily cached
odp.naglowek("content-type")     # case-insensitive
odp.naglowki()            # all headers, lowercase keys

odp.czy_sukces()          # 2xx
odp.czy_blad_klienta()    # 4xx
odp.czy_blad_serwera()    # 5xx
odp.czy_blad()            # 4xx or 5xx

The *_json variants skip the wrapper and return the parsed body directly:

niech user = api.get_json("/users/1")    # already a hash, not Odpowiedz
pokazl user["name"]

Typed exceptions

When rzucaj_bledy is on (the default), HTTP errors become typed AlexScript exceptions. The hierarchy lets you catch broadly or narrowly:

WyjatekPodstawowy
└── BladPosla                  # everything from posel
    ├── BladSieci              # connection refused, DNS, reset
    ├── BladTimeoutu           # request timed out
    ├── BladSerializacji       # JSON parse failed
    └── BladHttp               # status >= 400
        ├── BladHttpKlienta    # 4xx
        │   ├── BladZleZapytanie       # 400
        │   ├── BladNieautoryzowany    # 401
        │   ├── BladBrakDostepu        # 403
        │   ├── BladNieZnaleziono      # 404
        │   ├── BladKonfliktu          # 409
        │   └── BladPrzeciazenia       # 429
        └── BladHttpSerwera    # 5xx
            ├── BladWewnetrzny         # 500
            ├── BladBramy              # 502
            ├── BladNiedostepny        # 503
            └── BladTimeoutuBramy      # 504

In practice:

proba {
  api.get_json("/users/9999")
} zlap (e : Posel::BladNieZnaleziono) {
  pokazl "User not found"
} zlap (e : Posel::BladHttpKlienta) {
  pokazl "Other 4xx: " + e["wiadomosc"]
} zlap (e : Posel::BladSieci) {
  pokazl "Network problem: " + e["wiadomosc"]
} zlap (e : Posel::BladPosla) {
  pokazl "Anything else from posel"
}

If you’d rather inspect the response yourself, set rzucaj_bledy: falsz per call and check odp.czy_blad().

Interceptors

Interceptors are the “middleware” of an HTTP client — lambdas that see every request and response flowing through. Useful for logging, auth headers, automatic token refresh on 401, request timing, request IDs, and so on.

# Logger
api.dodaj_interceptor_zapytania(fn(zap) {
  pokazl "→ " + zap.metoda() + " " + zap.url()
  zwroc zap
})

api.dodaj_interceptor_odpowiedzi(fn(odp) {
  pokazl "← " + odp.status() + " " + odp.zapytanie().url()
  zwroc odp
})

A more involved example — measuring per-request time using the meta() slot for cross-interceptor communication:

api.dodaj_interceptor_zapytania(fn(zap) {
  zap.ustaw_meta("start", Czas.teraz().timestamp_f())
  zwroc zap
})

api.dodaj_interceptor_odpowiedzi(fn(odp) {
  niech ms = (Czas.teraz().timestamp_f() - odp.zapytanie().meta("start")) * 1000
  pokazl odp.zapytanie().url() + " took " + ms + "ms"
  zwroc odp
})

Request interceptors run in registration order (FIFO); response interceptors run in reverse (LIFO) so the last-registered one is closest to the wire.

Async and true parallelism

Every sync method has an _async counterpart that returns an Obietnica:

asynchroniczna funkcja main() {
  niech user = czekaj api.get_json_async("/users/1")
  pokazl user["name"]
}
uruchom(main)

A subtlety worth knowing: czekaj on several promises in sequence is still sequential — each czekaj blocks the current fiber until that one promise settles. To run requests concurrently, wrap each in uruchom_rownolegle and combine the resulting promises with Obietnica.wszystkie:

asynchroniczna funkcja pobierz_wszystkich() {
  niech a = uruchom_rownolegle(fn() { czekaj api.get_json_async("/users/1") })
  niech b = uruchom_rownolegle(fn() { czekaj api.get_json_async("/users/2") })
  niech c = uruchom_rownolegle(fn() { czekaj api.get_json_async("/users/3") })

  niech wyniki = czekaj Obietnica.wszystkie([a, b, c])
  zwroc wyniki
}

uruchom(pobierz_wszystkich)

Now the three requests run concurrently — total wall time is roughly that of the slowest single request, not the sum. AlexScript’s fiber scheduler suspends each request on socket I/O and lets the others make progress.

Async exceptions work the same way as sync: czekaj re-raises the rejection reason as an AlexScript exception, so the typed exception hierarchy applies in async code too.

Architecture

The codebase is organized as a small module:

posel/
├── posel.as       # entry point + module facade
├── klient.as      # Klient class — sync + async methods, interceptors
├── zapytanie.as   # Zapytanie — mutable request flowing through pipeline
├── odpowiedz.as   # Odpowiedz — response wrapper with lazy JSON
├── pipeline.as    # interceptor chain runner
├── url.as         # URL joining and query string helpers
└── bledy.as       # exception hierarchy

A request flows through the pipeline like this: client builds a Zapytanie from its config and the per-call options, request interceptors run in FIFO order, the underlying Http::* call happens (network errors get translated to typed exceptions), the raw response gets wrapped in Odpowiedz, response interceptors run in LIFO order, and finally — if rzucaj_bledy is on and the status is ≥ 400 — the matching BladHttp... is raised.

The reason Zapytanie is a class rather than a hash: interceptors need to mutate it (headers, query params, body, even the URL). A class with named accessors makes interceptors readable and catches typos at call time instead of inside someone’s production retry loop.

Repository

github.com/N3BCKN/posel

Algorithms and data structures

The main AlexScript repository ships with an examples/ directory of small, self-contained programs — classic algorithms, data structures, and design patterns implemented idiomatically in AlexScript. They’re worth reading for two reasons: as a sanity check on how features compose in real code (recursion, closures, classes, generics-by-duck-typing), and as a starting point if you want to copy-paste a working implementation rather than write your own from scratch.

The collection covers the usual algorithm-textbook ground — sorting, searching, hashing, basic graph traversal — plus a handful of object-oriented examples illustrating common design patterns. None of these are framework code; they’re plain .as files you can run directly.

A few representative samples below.

Bubble sort

A clean implementation showing array indexing, the nested numeric dla loops, and the three-line classic swap:

funkcja bubble_sort(tablica) {
    niech n = tablica.dlg
    dla niech k = 0; n - 1; 1 {
        dla niech j = 0; n - k - 1; 1 {
            jesli tablica[j] > tablica[j + 1] {
                niech tmp = tablica[j]
                tablica[j] = tablica[j + 1]
                tablica[j + 1] = tmp
            }
        }
    }
    zwroc tablica
}

Notice tablica[j], tablica[j + 1] = tablica[j + 1], tablica[j] would be tighter in some languages — AlexScript doesn’t have multi-assignment, so the explicit tmp swap is the idiomatic form.

Standard halving-the-search-space iteration on a sorted array. The midpoint computation defends against integer/float drift by checking the type and rounding if needed:

funkcja binary_search(tablica, szukana) {
    niech lewy = 0
    niech prawy = tablica.dlg - 1

    dopoki lewy <= prawy {
        niech srodek = (lewy + prawy) / 2
        srodek = srodek.typ() == 'calkowita' ? srodek : srodek.zaokragl()
        jesli tablica[srodek] == szukana {
            zwroc srodek
        } albojesli tablica[srodek] < szukana {
            lewy = srodek + 1
        } albo {
            prawy = srodek - 1
        }
    }
    zwroc -1
}

Returns -1 for “not found” — the conventional sentinel.

Singleton pattern

A textbook Singleton, illustrating static class members, a guarded constructor, and the pobierz() accessor that lazily creates the instance on first use:

klasa KonfiguracjaAplikacji {
    statyczny niech instancja = nic
    statyczny niech zainicjalizowana = falsz

    funkcja konstruktor() {
        jesli KonfiguracjaAplikacji.zainicjalizowana {
            rzuc "Nie można tworzyć instancji Singleton! Użyj KonfiguracjaAplikacji.pobierz()"
        }

        niech @ustawienia = {}
        niech @wersja = "1.0.0"

        niech @ustawienia["baza_danych"] = "localhost"
        niech @ustawienia["port"] = 5432
        niech @ustawienia["timeout"] = 30
    }

    statyczny funkcja pobierz() {
        jesli KonfiguracjaAplikacji.instancja == nic {
            niech KonfiguracjaAplikacji.zainicjalizowana = prawda
            niech KonfiguracjaAplikacji.instancja = KonfiguracjaAplikacji.nowy()
            niech KonfiguracjaAplikacji.zainicjalizowana = falsz
        }
        zwroc KonfiguracjaAplikacji.instancja
    }

    funkcja ustaw(klucz, wartosc) {
        @ustawienia[klucz] = wartosc
    }

    funkcja pobierz_ustawienie(klucz) {
        zwroc @ustawienia[klucz]
    }

    funkcja wyswietl_konfiguracje() {
        pokazl "Konfiguracja aplikacji v" + @wersja + ":"
        dla klucz, wartosc w @ustawienia {
            pokazl "  " + klucz + " = " + wartosc
        }
    }
}

Direct construction is blocked — only KonfiguracjaAplikacji.pobierz() returns a (always the same) instance. The zainicjalizowana flag is the trick that makes it work: the constructor checks the flag and refuses to run, but pobierz() flips it true just before its single legitimate nowy() call, then flips it back.

The full directory has more — different sorts (insertion, selection, quicksort, merge), search variants, linked lists and stacks, hashing, and a handful of other GoF patterns alongside Singleton. Browse the directory directly at github.com/N3BCKN/alexscript/tree/master/examples to see what’s available.