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:
- Output and input —
PRINTwith comma/semicolon separators andTAB(n),INPUTwith optional prompts and multi-value reads. - Assignment —
LET(or implicit), with scalar variables, arrays (A(I)), and multi-dimensional arrays (GRID(I, J)). - Control flow —
IF / THEN / ELSE,GOTO,FOR / NEXTwith optionalSTEP,GOSUB/RETURN,END. - Arrays —
DIMwith 0-based inclusive indices:DIM A(10)gives 11 slotsA(0)..A(10). Multi-dimensional arrays are supported. - User functions —
DEF FN NAME(args) = expressionfor one-liner functions, including recursive calls and parameters that shadow globals during the call. - Data tables —
DATAdeclarations harvested from anywhere in the program during a pre-pass, consumed sequentially byREAD, rewindable withRESTORE. - Comments —
REMeither on its own line or trailing, plus:to chain multiple statements on one line.
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:
- A
Runtimeclass holds all the mutable interpreter state — program buffer, variables, arrays, FOR/GOSUB stacks, the DATA pool, flags. There are no globals floating between modules. Every component takes aRuntimeinstance and operates on it. This makes the code much easier to reason about than the equivalent BASIC interpreter built around module-level state would be. - Sorted line index. A parallel sorted array of integer line numbers lives next to the program buffer, so
RUNandGOTOdon’t pay the cost of re-sorting on every iteration. - Two-pass program execution. The first pass scans for
DATAdeclarations and builds the data pool; the second pass runs the program normally.DATAlines are no-ops during normal flow — that’s why they can appear anywhere in the source. - Index-based lexer. Rather than mutating a character array (the typical Ruby idiom), the lexer keeps a
positioncursor on the source string. Same semantics, no per-token allocation.
Repository
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:
- HTTP/1.1 server with keep-alive, configurable timeouts, and graceful shutdown.
- Routing — static paths (
/users), parametric (/users/:id), wildcards (/static/*), and full regex patterns. - Middleware chain — composable pipeline with built-in logger, CORS, rate limiter, and signed sessions.
- Body parsing dispatched automatically by
Content-Type— JSON arrives as an AlexScript object, form-urlencoded as a hash of fields, anything else as the raw string. - Cookie API with full attribute support:
HttpOnly,Secure,SameSite,Max-Age,Path,Domain. - Sessions — HMAC-signed cookies with an in-memory store, configurable cookie name and TTL.
- Static file serving with ETag, Last-Modified, conditional GET (304 Not Modified), and path traversal protection.
- Streaming responses that use constant memory regardless of file size.
- Content negotiation —
Accept:header parsing with q-values and type wildcards. - Method handling — automatic
HEADsupport,405 Method Not Allowedwith a correctAllow:header for unsupported verbs.
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
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
- All HTTP verbs —
get,post,put,patch,delete,head,options. - JSON convenience methods —
get_json,post_json, etc., with auto serialization, parsing, and headers. - Configurable client instances with default headers, base URL, query parameters, timeout, and redirect limits — overridable on a per-call basis.
- Typed exception hierarchy — catch
BladNieZnalezionofor 404s,BladHttpKlientafor any 4xx,BladPoslafor anything from posel. - Request and response interceptors — Axios-style middleware chain (FIFO for requests, LIFO for responses).
- Async variants for every method — true parallel I/O via
uruchom_rownolegleandObietnica.wszystkie. - Module facade for one-off requests:
Posel::get(...)skips client construction entirely.
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
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.
Binary search
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.