LOOK Language
developer experience driven language design
A scripting language designed for web applications.
Routing, database and HTTP — built into the language. Simple to install, clean to write.
What is LOOK?
LOOK is a language designed to keep web development as direct as possible. Define a route, connect to a database, return JSON — without setting up a framework or writing configuration files.
The language is written in C++23 and can be copied into an Apache/XAMPP environment and run. In FastCGI mode, the server loads the script once when it starts; every subsequent request is handled through the already-warm runtime.
# A LOOK application starts this simply
$conn = db::connect("mysql://root:@127.0.0.1/blog")
route("GET", "/posts", function() use ($conn) {
$posts = db::query($conn, "SELECT id, title, date FROM posts ORDER BY date DESC", [])
print(json::encode(["ok" => true, "data" => $posts]))
})
route("GET", "/posts/{id}", function($id) use ($conn) {
$rows = db::query($conn, "SELECT * FROM posts WHERE id=?", [$id])
if (count($rows) == 0) {
return response::error(404, "Not found") # status + {"ok":false,"error":...}
}
response::json(["ok" => true, "data" => $rows[0]])
})
route("404", fn() => response::error(404, "Endpoint not found"))
Technical Summary
| Feature | Detail |
|---|---|
| Runtime | C++23 — register-based bytecode VM (default engine, ~41–51× faster on the CLI) with a tree-walk interpreter as a safety-net fallback |
| Web mode | FastCGI — port 9000 (production) · HTTP mode (WebSocket) · CGI (fallback) |
| Database | MySQL · SQLite · PostgreSQL — zero dependency, wire protocol |
| Install | Copy the binary, register it with Apache, run |
| GC | Reference counting |
| File extension | .lk |
Getting Started
5 steps to install LOOK and run your first application — no framework, no configuration file.
🧩 Editor support: a VS Code extension for .lk files — IntelliSense (completion + hover + signature for every built-in), live error checking (syntax + undefined function calls, underlined as you type), completion for your own variables/functions, outline, snippets and Run/Serve/REPL shortcuts. Install from the Marketplace, or download look-lang-2.0.0.vsix from GitHub Releases and install it with code --install-extension look-lang-2.0.0.vsix.
⊞ Windows (XAMPP)
- Download
look-windows-x64.zipfrom GitHub Releases - Copy
look-fcgi.exeandlook-cgi.exeintoC:\xampp\cgi-bin\ - Add to
C:\xampp\htdocs\.htaccess:Options +ExecCGI - Create
C:\xampp\htdocs\index.lk(the example below) - Start look-fcgi:
look-fcgi.exe --port 9000 - Start Apache, open
http://localhost/
Detailed guide: xampp/setup.md
⊞ Linux (Ubuntu / AlmaLinux)
- Download the binary for your platform from GitHub Releases
- Copy the binary and give it the execute bit:
cp look-fcgi /opt/look/ && chmod +x /opt/look/look-fcgi - Create an
index.lkfile in your app directory - Write a systemd service (see the FastCGI section)
- Add the Apache
.htaccessrewrite rules (see the Apache section) systemctl start look-fcgi→curl http://localhost/
Detailed guide: docs/ubuntu-deployment.md
Your first index.lk
# index.lk — this file is everything; no framework, no config
$conn = db::connect("mysql://root:@127.0.0.1/blog") # DB optional
route("GET", "/", function() {
print(json::encode(["ok" => true, "message" => "Hello LOOK!"]))
})
route("GET", "/posts/{id}", function($id) use ($conn) {
$rows = db::query($conn, "SELECT * FROM posts WHERE id=?", [$id])
if (count($rows) == 0) { response::status(404) print("Not found") return }
print(json::encode($rows[0]))
})
route("404", function() {
response::error(404, "Not found")
})
Learning Path
| Step | What you'll learn | Section | Time |
|---|---|---|---|
| 1 — Language basics | Variables, operators, strings, if/for/switch, functions, closures | Variables → Functions | ~1 hour |
| 2 — Web layer | route(), request::, response::, json::, 404 handler | Routing | ~30 min |
| 3 — Database | db::connect, db::query, db::exec, automatic type conversion, db_check() pattern | Database | ~45 min |
| 4 — Session & security | session::, cookie::, auth:: (PBKDF2), validator::, html::escape | Session → Auth | ~30 min |
| 5 — Templates & file module | template::render, layout inheritance, splitting code with use "file.lk" | Template → File Module | ~45 min |
| 6 — Production | FastCGI warm start, --workers N, hot reload, log::, .env | FastCGI → Concurrent | ~1 hour |
| 7 — Real-time | WebSocket route("WS",...), SSE route("SSE",...), timer::every | WebSocket → SSE | ~1 hour |
| 8 — Background jobs | parallel() + channel(), jobs::push/run/recover, cache::, queue:: | Parallel → Jobs | ~1 hour |
Binary Selection
| What you want | Binary to use | Command |
|---|---|---|
| Production web app (Apache/nginx) | look-fcgi | look-fcgi --port 9000 --workers 8 |
| WebSocket / SSE / real-time | look-fcgi --mode http | look-fcgi --mode http --port 8090 --workers 4 index.lk |
| CLI script / language testing | lk | lk script.lk |
| Syntax + undefined-call check (without running) | lk --check | lk --check script.lk → OK or CHECK <line> <column> <message> |
| Interactive shell | lk repl | lk repl |
| Run tests | lk test | lk test tests/ --verbose |
| Install a package | lk install | lk install github.com/user/repo |
lk --check only parses and reports undefined function calls (e.g. echo(...)) — it does not run the code (no DB/mail is triggered). The VS Code extension uses this for live error checking.
Design Decisions
LOOK deliberately leaves some things out. These aren't limitations — they're design decisions.
Routing built into the language
There's no separate router library. route() is a built-in — when you run the file, routes register automatically, and the end of the script dispatches.
No global variables
HTTP data is accessed via request::get(), request::post(), request::json(). Sessions via session::, cookies via cookie::. Everything is explicit and traceable.
Function scope is isolated. Assigning a variable inside a function creates a function-local — it never silently overwrites an outer/top-level variable (reading an outer value still works). To share mutable state, register it once with app::set() and read it with app::get() / app::<name>().
Explicit closure capture
To access an outer variable you must write use ($conn). Which data goes where is always visible.
No OOP — on purpose
LOOK has no classes, interfaces or traits. The vast majority of web applications can be written with functions + data structures. For scenarios that need richer data modelling, an optional ORM-style module may be offered later — as a module, not a language feature.
No mixed arrays
Numeric arrays and associative arrays are separate concepts and don't blend together. Data-structure clarity is preferred.
Architecture
Binaries
| Binary | Purpose | Status |
|---|---|---|
| look-fcgi.exe | FastCGI — production (port 9000) | ✅ Active |
| look-cgi.exe | Apache CGI — fallback mode | ✅ Stable |
| look.exe | CLI interpreter | ✅ Stable |
Source Files (cpp/src/)
| File | Contents |
|---|---|
| lexer.cpp | Tokenizer, $ prefix, all token types |
| parser.cpp | Precedence-aware parser, AST nodes, SourceLocation |
| interpreter.cpp | Tree-walk interpreter, GC, route() setup_mode, dispatch_routes(), make_dispatch_copy() |
| stdlib.cpp | math:: string:: type:: array:: — all standard modules |
| extra_stdlib.cpp | env() config() runtime::stats() |
| web.cpp | route() request:: response:: json:: session:: cookie:: — HTTP primitives |
| web_stdlib.cpp | db:: auth:: validator:: html:: · ConnPool (per-DSN connection pool) |
| mysql_client.cpp | MySQL wire protocol — Winsock/POSIX, zero dependency |
| sqlite_client.cpp | SQLite driver — sqlite3 amalgamation, WAL+FK by default |
| postgres_client.cpp | PostgreSQL wire protocol v3 — MD5 auth, lastval(), zero dependency |
| logger.cpp | log:: — daily rotation, Windows FILE_SHARE_WRITE |
| file_stdlib.cpp | file:: — read/put/append/exists/remove/size/store · upload security layer |
| date_stdlib.cpp | date:: — now/today/format/parse/add/sub/diff/weekday/week |
| fcgi_main.cpp | FastCGI entry — WarmApp, warm start, hot reload, ThreadPool, concurrent dispatch |
| fcgi_protocol.cpp | FastCGI TCP wire protocol |
| http_main.cpp | --mode http entry point — HttpApp, hot reload, ws_handler, sse_handler |
| http_server.cpp | HTTP/1.1 parser, WorkerPool, WS upgrade, SSE upgrade, async_read/write |
| event_loop.cpp | Abstract EventLoop — epoll (Linux) / IOCP (Windows) |
| websocket.cpp | SHA-1, Base64, RFC 6455 frame codec, WsConnection, WsRegistry |
| sse.cpp | SseConnection, SseRegistry — SSE frame sending, disconnect detection |
| timer_stdlib.cpp | TimerManager singleton — background thread, after/every/cancel, interpreter binding |
| compiler.cpp | AST → FunctionProto bytecode compiler — RegisterAllocator, alloc_seq(), short-circuit &&/||, closure capture hints, try/catch TRY_PUSH/POP |
| vm.cpp | Register-based VM — switch(opcode) dispatch, CallFrame stack, TryCatchEntry, PARALLEL_CALL (thread detach), CHAN_*/WS_*/SSE_* opcodes |
| cgi_main.cpp | CGI entry — fallback mode |
| main.cpp | CLI entry |
Headers (cpp/include/look/)
token.h · lexer.h · parser.h · ast.h · interpreter.h · fcgi.h · http_server.h · event_loop.h · websocket.h · sse.h · timer.h · bytecode.h · compiler.h · vm.h · logger.h
Mode Compatibility
| Feature | --mode fcgi | --mode http | CLI (look) |
|---|---|---|---|
| route(), request::, response::, db:: | ✅ | ✅ | — (no HTTP context) |
| session::, cookie:: | ✅ | ✅ | — |
| parallel() + channel() | ✅ | ✅ | ✅ |
| WebSocket — route("WS",...) | ❌ | ✅ | — |
| SSE — route("SSE",...) | ❌ | ✅ | — |
| timer::after / every / cancel | ❌ | ✅ | — |
| log::, file::, date::, math:: etc. | ✅ | ✅ | ✅ |
Variables & Types
All variables start with $. No type declaration — dynamically typed.
Basic Types
$intval = 42
$decimal = 3.14
$text = "hello"
$flag = true
$none = null
Integers are 64-bit. Whole numbers hold the full signed 64-bit range (up to ±9.2×10¹⁸) — order IDs, phone numbers and DB BIGINT columns round-trip exactly, with no silent precision loss. Numbers print in their natural form (no scientific notation for ordinary magnitudes).
Number Literals
$hex = 0xFF # 255 — hexadecimal
$bin = 0b1010 # 10 — binary
$big = 1_000_000 # underscores group digits (ignored)
$sci = 6.022e23 # scientific notation (float)
$mask = 0xFF_FF # 65535 — underscores work in any base
Numeric Array
$list = [1, 2, 3, 4, 5]
print($list[0]) # 1
print(count($list)) # 5
Associative Array (Assoc Array)
$person = ["name" => "Ali", "age" => 30, "city" => "Izmir"]
print($person["name"]) # Ali
print($person["age"]) # 30
# Update a value
$person["city"] = "Ankara"
Nested Array
$product = [
"name" => "Burger",
"price" => 89.90,
"tags" => ["veggie", "gluten-free"]
]
print($product["tags"][0]) # veggie
No mixed arrays: a single array can't use string keys and numeric indexes at the same time. Nested arrays (numeric inside assoc) are not covered by this rule — each array can be freely nested as long as it is homogeneous within itself.
Type Conversions
$s = "42"
$n = int($s) # string → int
$f = float($s) # string → float
$b = bool($s) # string → bool
$t = string(99) # int → string
When are int() / float() needed? DB columns convert automatically — an INT column already comes back as int, a DECIMAL column already as float. Conversion is only needed for user input: int(request::get("id")), float(request::post("amount")).
Built-in Functions
| Function | Description |
|---|---|
| count($arr) | Number of array elements |
| int($v) | Convert to integer |
| float($v) | Convert to float |
| string($v) | Convert to string |
| bool($v) | Convert to boolean |
| print($v) | Output (writes to the HTTP response body) |
| write($v) | Same as print() |
Operators
Arithmetic
$a + $b # addition
$a - $b # subtraction
$a * $b # multiplication
$a / $b # division
$a % $b # modulo
$a ** $b # power (2**10 = 1024)
String Concatenation
$full = "Hello" . " World" # "Hello World"
$full .= "!" # "Hello World!"
Comparison
$a == $b # equal
$a != $b # not equal
$a < $b # less than
$a > $b # greater than
$a <= $b # less than or equal
$a >= $b # greater than or equal
$a <=> $b # spaceship: -1 / 0 / 1
Type-strict equality (no coercion), Python-like. == never coerces across types — different kinds are simply not equal, so there's no 0 == "abc" footgun. (Numeric kinds compare with each other: 1 == 1.0 is true — like Python, unlike Go which rejects mixed int/float at compile time.)
0 == "abc" # false (number vs string — never equal)
"5" == 5 # false (string vs number)
5 == 5.0 # true (int and float are one "number" kind)
null == 0 # false (null only equals null)
Form input arrives as strings, so convert before comparing to a number: int(request::get("id")) == 42. Ordering (< >) works within a kind (number↔number, string↔string); comparing a number to a string raises a runtime error rather than guessing.
Logical
$a && $b # and
$a || $b # or
!$a # not
Bitwise
$a & $b # AND
$a | $b # OR
$a ^ $b # XOR
~$a # NOT
$a << 2 # left shift
$a >> 2 # right shift
Compound Assignment
$x += 5 $x -= 3 $x *= 2 $x /= 4
$x %= 7 $x .= "!" $x &= 0xFF $x |= 0x01
$x ^= 0xF0
Increment / Decrement
$i++ # postfix
++$i # prefix
$i-- # postfix
--$i # prefix
Null Coalescing
$name = $data["name"] ?? "Unknown"
# Chained:
$val = request::get("name") ?? request::post("name") ?? "default"
Ternary Operator
$label = $active ? "Active" : "Inactive"
$status = $age >= 18 ? "Adult" : "Child"
# Nested ternary
$grade = $score >= 90 ? "A" : $score >= 70 ? "B" : $score >= 50 ? "C" : "F"
# Combined with null coalescing
$name = request::get("name") ?? ""
$msg = $name != "" ? "Hello {$name}" : "Hello guest"
Strings & Interpolation
Double Quotes — Interpolation On
$name = "Ahmet"
$age = 25
print("Hello {$name}!") # Hello Ahmet!
print("{$name} is turning {$age+1}.") # Ahmet is turning 26.
print("Total: {$price * 1.18} USD") # full expression
print("Result: {compute($x, $y)}") # function call
Single Quotes — Raw String
$s = 'This $variable is not interpolated' # literal $variable
Backtick — Raw String (no escaping, interpolation on)
$json = `{"key": "value", "num": 42}`
$sql = `
SELECT u.*, c.name AS category
FROM products u
JOIN categories c ON c.id = u.category_id
WHERE u.company_id = {$id}
ORDER BY u.sort
`
print(`C:\Users\{$user}\Desktop`) # backslashes just work
Tip: use a backtick raw string for multi-line SQL queries — no escaping mess.
Escape Sequences (inside double quotes)
| Sequence | Meaning |
|---|---|
| \n | Newline |
| \t | Tab |
| \r | Carriage return |
| \b \f \0 | Backspace · form feed · null |
| \\ | Backslash |
| \" | Double quote |
| \uXXXX | Unicode code point → UTF-8 (surrogate pairs for emoji: 😀) |
| \{ | Curly brace (does not start interpolation) |
Control Flow
if / elseif / else
if ($age < 18) {
print("Child")
} elseif ($age < 65) {
print("Adult")
} else {
print("Senior")
}
Ternary ?:
$label = $active ? "Active" : "Inactive"
# Nested chaining
$msg = $score >= 90 ? "Excellent" : ($score >= 70 ? "Good" : "Study more")
switch
Go-style switch — no break needed, each case exits automatically (no fall-through). Multiple case values are written comma-separated. A break written by habit is harmless: it ends the switch only, never the enclosing loop; a continue inside a case applies to the surrounding loop as expected.
switch($status) {
case "active":
print("Running")
case "inactive", "pending":
print("Stopped")
default:
print("Unknown")
}
Numeric value
switch($http_code) {
case 200:
print("OK")
case 404:
print("Not found")
case 500, 503:
print("Server error")
default:
print("Other")
}
switch(true) — range pattern
switch(true) {
case $score >= 90:
print("A")
case $score >= 80:
print("B")
case $score >= 70:
print("C")
default:
print("F")
}
Type-strict matching. case uses the same type-strict == — a numeric case 1 does not match a string subject "1". Request/route params arrive as strings, so either match the string (case "1") or convert the subject once: switch (int(request::get("id"))) { case 1: … }.
Note: you don't need to write break — each case exits automatically, no accidental fallthrough. If you need deliberate fallthrough, group cases with commas.
Loops
while
$i = 0
while ($i < 5) {
print("{$i} ")
$i++
}
for
for ($i = 0 $i < 10 $i++) {
print("{$i}\n")
}
foreach — numeric array
$colors = ["red", "green", "blue"]
foreach ($colors as $color) {
print($color . "\n")
}
foreach — associative array
$person = ["name" => "Ali", "age" => 30]
foreach ($person as $key => $value) {
print("{$key}: {$value}\n")
}
break / continue
foreach ($list as $item) {
if ($item == "skip") { continue }
if ($item == "stop") { break }
print($item)
}
Functions
Named Function
function add($a, $b) {
return $a + $b
}
print(add(3, 7)) # 10
Default Parameters
Trailing parameters can carry a default value ($param = expr). If the argument is omitted at the call site, the default is used — evaluated fresh on each call. A default may reference earlier parameters.
function greet($name, $greeting = "Hello") {
return $greeting . ", " . $name
}
print(greet("Ali")) # Hello, Ali
print(greet("Ali", "Hi")) # Hi, Ali
# works with fn / arrow too
$box = fn($w, $h = 10) => $w * $h
print($box(5)) # 50
Parameters without a default remain required — omitting one is a clear runtime error, never a silent null.
Anonymous Function (Lambda)
$square = function($x) {
return $x * $x
}
print($square(5)) # 25
Short Lambda — fn + arrow (=>)
fn is an exact alias of function; the expression-bodied arrow
form shortens one-line callbacks. Both can be used anywhere.
# expression-bodied arrow: body is a single expression, auto return
$square = fn($x) => $x * $x
$add = fn($a, $b) => $a + $b
# use capture + arrow together
$scale = fn($n) use ($factor) => $n * $factor
# higher-order — ideal in route callbacks
route("GET", "/ping", fn() => response::json(["ok" => true]))
# a block body can also be written with fn (multi-line)
$greet = fn($name) { return "hello " . $name }
Variadic (Variable Number of Parameters)
function sum_all(...$numbers) {
$total = 0
foreach ($numbers as $s) {
$total += $s
}
return $total
}
print(sum_all(1, 2, 3, 4, 5)) # 15
Recursive
function factorial($n) {
if ($n <= 1) { return 1 }
return $n * factorial($n - 1)
}
print(factorial(10)) # 3628800
Stack overflow protection: maximum depth of 500. For deep recursion, write it iteratively.
Function as a Value
function apply($fn, $value) {
return $fn($value)
}
$result = apply(function($x) { return $x * 2 }, 21)
print($result) # 42
Closures & Capture
Closures can't access outer variables automatically — they must be captured explicitly with use.
$factor = 3
$triple = function($x) use ($factor) {
return $x * $factor
}
print($triple(7)) # 21
Closures with route — DB Connection
$conn = db::connect("mysql://root:@127.0.0.1/mydb")
# without use, $conn is invisible — BUG
route("GET", "/products", function() {
$rows = db::query($conn, "SELECT * FROM products", []) # $conn = null!
})
# CORRECT
route("GET", "/products", function() use ($conn) {
$rows = db::query($conn, "SELECT * FROM products", [])
print(json::encode($rows))
})
Capturing Multiple Values
route("POST", "/action", function() use ($conn, $config, $limit) {
# $conn, $config, $limit are all accessible
})
app:: — Service Registry (an alternative to capture)
There are no global variables, so a shared connection has to be carried into every route
with use ($conn). With many services or nested
closures this repetition gets tiring. app:: registers a service
once at setup; routes access it without capture.
The explicit-capture philosophy is preserved — use still applies,
and app:: is just an optional escape hatch.
# setup — once
app::set("db", db::connect(env("DB_DSN")))
app::set("config", ["limit" => 50])
# route — NO use ($conn)
route("GET", "/menu/{company}", function($company) {
$conn = app::db() # shortcut for app::get("db")
$cfg = app::get("config")
# ...
})
| Function | Description |
|---|---|
| app::set("name", $value) | Register a service (usually at setup) |
| app::get("name") | Fetch a service (null if absent) |
| app::has("name") | Is it registered? (bool) |
| app::db() | shortcut for app::get("db") — the most common use |
Concurrency: services are kept in a single registry shared across all
dispatches (mutex-protected). Today's use ($conn) already shares the same
connection, so the behaviour is identical — thread-safety is unchanged.
try / catch / finally
try {
$conn = db::connect("mysql://root:@127.0.0.1/db")
$rows = db::query($conn, "SELECT * FROM missing", [])
} catch ($err) {
log::error("DB error: {$err}")
print(json::encode(["ok" => false, "error" => $err]))
} finally {
# runs in every case
log::info("Operation complete")
}
catch variable: use catch ($e) to capture the error message as a string.
Standard Pattern — db_check()
$conn = null
try {
$conn = db::connect(
"mysql://" . env("DB_USER", "root") . ":" . env("DB_PASS", "")
. "@" . env("DB_HOST", "127.0.0.1") . "/" . env("DB_NAME", "mydb")
)
} catch ($e) {
log::error("DB connection error: {$e}")
}
function db_check($conn) {
if ($conn == null) {
response::error(503, "Database unavailable")
return false
}
return true
}
route("GET", "/products", function() use ($conn) {
if (!db_check($conn)) { return }
# ... normal flow
})
Error System (Phase 9.8)
LOOK errors are SourceLocation-based — with file name, line and column. Parse errors and runtime errors are thrown as separate types.
Error Types
| Type | When | Output |
|---|---|---|
LookParseError | Syntax error — at compile time | [PARSE ERROR] index.lk:12:5 — expected '}' |
LookRuntimeError | Runtime error (null access, type error, etc.) | [RUNTIME ERROR] index.lk:42:3 — field access on a null value |
Stack Trace
Runtime errors also show the call stack:
[RUNTIME ERROR] index.lk:88:5 — undefined variable: $user_id
Stack trace:
admin_check() index.lk:32
route callback index.lk:88
dispatch_routes() [runtime]
Throwing your own errors — throw
Use the throw keyword to raise a business-logic error that try/catch will catch. You can throw any value — a plain string, or a structured error from error::new(). The thrown value is bound to the catch ($e) variable.
# Throw a plain string
function check_age($age) {
if ($age < 18) { throw "too young" }
return true
}
try { check_age(15) } catch ($e) { print($e) } # "too young"
# Throw a structured error (carries type + code)
throw error::new("unauthorized", "Invalid token", 401)
error::new(...) throws by itself as well, so throw is optional in front of it — both forms are equivalent. Use throw "msg" when you just need a quick string error.
Catching with try/catch
Runtime errors and string throws arrive as their value; structured errors as an assoc — read them safely with error::message() / error::code():
try {
$conn = db::connect("mysql://root:@127.0.0.1/mydb")
$rows = db::query($conn, "SELECT * FROM nonexistent_table", [])
} catch ($e) {
log::error("DB error: {$e}")
response::status(500)
print(json::encode(["ok" => false, "error" => $e]))
}
Stack Overflow Protection
Recursive calls that exceed the 500-depth limit throw a LookRuntimeError — the process doesn't crash.
function infinite($n) { return infinite($n + 1) }
try {
infinite(0)
} catch ($e) {
print($e) # "Stack overflow: maximum call depth (500) exceeded"
}
runtime::stats()
Runtime statistics — to monitor memory usage and request count:
route("GET", "/monitor", function() {
print(json::encode(runtime::stats()))
})
Error handling in FastCGI: if a route callback throws an uncaught exception, the look-fcgi process is unaffected — only that request gets a 500. Other worker threads keep running.
error:: — Structured Errors
error:: has been a core module since v1.x — no use error; needed, always ready. It's used to throw and catch structured errors that carry type information and a code.
API
| Function | Description | Returns |
|---|---|---|
error::new($type, $msg [,$code]) | Throw a structured error | — (throws) |
error::is($e, $type) | Type check inside a catch block | bool |
error::message($e) | Safely get the error message | string |
error::code($e) | Get the HTTP/application error code | int |
Basic Usage
# Throw a structured error
function find_user($conn, $id) {
$rows = db::query($conn, "SELECT * FROM users WHERE id=?", [$id])
if (count($rows) == 0) {
error::new("not_found", "User not found: {$id}", 404)
}
return $rows[0]
}
route("GET", "/user/{id}", function() use ($conn) {
try {
$u = find_user($conn, request::param("id"))
print(json::encode($u))
} catch ($e) {
if (error::is($e, "not_found")) {
response::status(error::code($e))
print(json::encode(["ok" => false, "error" => error::message($e)]))
} else {
response::status(500)
print(json::encode(["ok" => false, "error" => $e]))
}
}
})
Auto-loaded as a Core Module
Along with log::, file:: and date::, error:: is a core module — you don't need to write use error;. It's available directly in the setup phase and in all route callbacks.
Module System
use math # math:: prefix
use string # string:: prefix
use array # array:: prefix
use string as str # with an alias
print(math::sqrt(144)) # 12
print(string::upper("hello")) # HELLO
print(str::length("test")) # 4 (using the alias)
Core modules (route, request::, response::, json::, session::, cookie::, db::, env, log::) are always active — no use needed.
struct — Grouping Data
Inspired by Go, struct is designed purely for grouping data, without inheritance or methods. There is no OOP — on purpose.
Definition
struct User {
name
age
email
}
# Fields with default values
struct Product {
name
price: 0.0
stock: 0
active: true
}
Literals and Field Access
$u = User{name: "Ali", age: 30, email: "ali@example.com"}
print($u.name) # Ali
print($u.age) # 30
# Update a field
$u.name = "Mehmet"
print($u.name) # Mehmet
# Unspecified fields take the default value
$pr = Product{name: "Burger"}
print($pr.price) # 0
print($pr.active) # true
Nested Struct
struct Address {
city
district
}
struct User {
name
address
}
$k = User{name: "Ali", address: Address{city: "Istanbul", district: "Kadıköy"}}
print($k.address.city) # Istanbul
Nested & Chained Assignment
You can assign through a chained path — nested struct fields, array elements, and assoc values all update in place (structs and arrays are reference types):
$k.address.city = "Ankara" # nested struct field
$users = [User{name: "A"}, User{name: "B"}]
$users[0].name = "Ali" # field of an array element
$grid = [[1, 2], [3, 4]]
$grid[0][1] = 99 # nested array index
$m["user"].active = true # field of an assoc value
Array of Structs
$products = [
Product{name: "Burger", price: 45.0, stock: 10},
Product{name: "Pizza", price: 89.9, stock: 5},
Product{name: "Salad", price: 25.0, stock: 20}
]
foreach ($products as $u) {
print($u.name . " — " . $u.price . " USD")
}
JSON Encode
$u = User{name: "Ali", age: 30, email: "ali@example.com"}
print(json::encode($u))
# {"name":"Ali","age":30,"email":"ali@example.com"}
# Note: the runtime tag (__struct__) is not included in the JSON
Structs have no methods — on purpose. Behavior is defined with separate functions (
function greet($u) { ... }).No inheritance, interfaces or visibility — simplicity is preserved.
No type coercion — fields take dynamic types.
const / iota — Constant Groups
Constant groups inspired by Go's const + iota approach. There is no enum keyword — const is enough.
Basic iota
const {
PENDING = iota # 0
ACTIVE # 1
INACTIVE # 2
DELETED # 3
}
if ($order.status == ACTIVE) {
print("Your order is active.")
}
switch ($order.status) {
case PENDING: print("Pending")
case ACTIVE: print("Active")
case INACTIVE: print("Inactive")
}
Expression iota — Go-Style Chain
const {
SMALL = iota * 10 # 0 (iota=0 → 0*10)
MEDIUM # 10 (iota=1 → 1*10)
LARGE # 20 (iota=2 → 2*10)
}
# Entries without a value re-evaluate the previous expression with the new iota
# (identical to Go's behavior)
Constant Values (without iota)
const {
HTTP_OK = 200
HTTP_NOT_FOUND = 404
HTTP_ERROR = 500
}
const {
ROLE_ADMIN = "admin"
ROLE_EDITOR = "editor"
ROLE_MEMBER = "member"
}
response::status(HTTP_NOT_FOUND)
print(json::encode(["error" => "Not found"]))
const + struct Together
const {
ROLE_ADMIN = "admin"
ROLE_EDITOR = "editor"
}
struct User {
name
role: "member"
}
$admin = User{name: "Can", role: ROLE_ADMIN}
print($admin.name . " — " . $admin.role) # Can — admin
const is global — accessible from all functions and closures without use.
route() — Routing
Routing is built into the language. No framework to set up.
Basic Usage
route("GET", "/", function() {
print(json::encode(["message" => "Hello World!"]))
})
route("POST", "/user", function() {
# handle the POST request
})
route("PUT", "/user/{id}", function($id) {
# the $id parameter is passed automatically
})
route("DELETE", "/user/{id}", function($id) {
# ...
})
URL Parameters
# Single parameter
route("GET", "/product/{id}", function($id) {
print("Product ID: {$id}")
})
# Multiple parameters
route("GET", "/menu/{company}/{category}", function($company, $category) {
print("{$company} / {$category}")
})
Parameters use {name} braces and arrive both as function arguments (in order) and via request::param("name") — use whichever reads better.
Static routes win. A static path always takes priority over a parameterized one, regardless of registration order — so /user/new matches its own handler even when /user/{id} is registered first. Matching is deterministic, not order-dependent.
404 Handler
route("404", fn() => response::error(404, "Endpoint not found"))
No route::run(). The end of the script dispatches automatically. You never need to call run().
Global Middleware — before_route()
before_route(fn) applies to every request and runs before route matching. You can stop the chain with stop().
# Add a header to every request
before_route(function() {
response::header("X-Powered-By", "LOOK")
})
# Token check — stop with stop() if it fails
before_route(function() {
$token = request::header("Authorization")
if (!$token) {
response::json(["error" => "Unauthorized"], 401) # status = 2nd arg
stop()
}
})
Multiple before_route() handlers can be defined — they run in registration order. If any one calls stop(), the rest and the route handler don't run.
Route-Level Middleware
To apply middleware to specific routes, use the 4-argument form. The middleware array is evaluated at route-registration time (not per request).
$auth = function() {
$token = request::get("token")
if ($token != "secret") {
response::json(["error" => "Unauthorized"], 401)
stop()
}
}
$require_role = function($role) {
return function() use ($role) {
if (request::get("role") != $role) {
response::json(["error" => "Forbidden"], 403)
stop()
}
}
}
$require_admin = $require_role("admin") # runs at setup time
# No middleware — the old syntax is unchanged
route("GET", "/public", function() {
response::json(["ok" => true])
})
# Auth-protected route
route("GET", "/private", [$auth], function() {
response::json(["route" => "private"])
})
# Chained middleware — runs in order
route("GET", "/admin", [$auth, $require_admin], function() {
response::json(["route" => "admin"])
})
Dispatch order: before_route() → route-level middleware → route handler. stop() breaks the chain at any point; it means the response has already been set.
route::
route() itself is a built-in, but the route:: module gives access to information about the active route.
| Function | Description |
|---|---|
| route::param("k") | URL parameter — same as request::param() |
| route::matched() | Returns the matched route pattern as a string |
route("GET", "/product/{id}", function($id) {
# $id comes in directly as a parameter — the preferred way
# Alternative — via the route module:
$id2 = route::param("id")
$pattern = route::matched() # "/product/{id}"
})
request::
| Function | Description | Example |
|---|---|---|
| request::method() | HTTP method | "GET", "POST"... |
| request::get("k") | Query string parameter | ?q=burger → "burger" |
| request::post("k") | Form POST field | form field |
| request::body() | Raw body (JSON) | raw string |
| request::json() | JSON body → assoc array | {"k":"v"} → array |
| request::ip() | Client IP | "192.168.1.1" |
| request::path() | URL path | "/menu/burger-cafe" |
| request::param("k") | URL parameter (from the route) | {id} → "42" |
| request::all() | GET + POST combined | assoc array |
| request::header("k") | HTTP header value | "Bearer ..." |
| request::is_get() | Is the method GET? | bool |
| request::is_post() | Is the method POST? | bool |
| request::is_put() | Is the method PUT? | bool |
| request::is_delete() | Is the method DELETE? | bool |
| request::is_patch() | Is the method PATCH? | bool |
| request::is_head() | Is the method HEAD? | bool |
| request::is_options() | Is the method OPTIONS? | bool |
route("POST", "/login", function() {
$body = request::json()
$email = $body["email"] ?? ""
$password = $body["password"] ?? ""
# ...
})
response::
| Function | Description |
|---|---|
| response::status(200) | Set the HTTP status code |
| response::header("X","Y") | Add an HTTP header |
| response::redirect("/path") | 302 redirect |
| response::redirect("/path", 301) | Permanent redirect |
| response::json($data) | Content-Type: application/json + JSON-encode and print |
| response::json($data, 201) | The second arg also sets the HTTP status code (201, 400, 404, etc.) |
| response::text($str) | Content-Type: text/plain + write to the body |
| response::html($str) | Content-Type: text/html + write to the body |
| response::error(404, "message") | Status code + {"ok":false,"error":message} JSON — a one-line error response |
response::error collapses the frequently-repeated 3-line pattern into one line:
# before — 3 lines
response::status(404)
response::json(["ok" => false, "error" => "Not found"])
return
# after — one line (same output)
return response::error(404, "Not found")
# VERBOSE — every primitive by hand (to learn what they do)
route("GET", "/api/data", function() {
response::status(200) # 200 is the default anyway
response::header("Content-Type", "application/json") # response::json does this AUTOMATICALLY
response::header("Cache-Control", "no-cache") # custom header — needed by hand
print(json::encode(["ok" => true]))
})
# PRO — response::json handles Content-Type + status(200 default); custom headers remain
route("GET", "/api/data", function() {
response::header("Cache-Control", "no-cache")
response::json(["ok" => true])
})
# SHORTEST — one line when there's no custom header (fn + arrow)
route("GET", "/api/ping", fn() => response::json(["ok" => true]))
# response::json — shortcut: Content-Type automatic, optional status
route("POST", "/api/product", function() {
response::json(["id" => 42, "ok" => true], 201)
})
route("GET", "/api/missing", function() {
response::json(["error" => "not found"], 404)
})
json::
# Encode — array → JSON string
$data = ["name" => "Ali", "age" => 30, "list" => [1, 2, 3]]
$json = json::encode($data)
# → {"name":"Ali","age":30,"list":[1,2,3]}
# Decode — JSON string → array
$str = '{"key":"value"}'
$arr = json::decode($str)
print($arr["key"]) # value
# Typical REST endpoint
function json_ok($data) {
print(json::encode(["ok" => true, "data" => $data]))
}
function json_error($message, $code) {
response::error($code, $message)
}
Large integers (beyond 64-bit). Integers that fit a signed 64-bit range
(up to 9223372036854775807) decode to a native int. Integers larger than that
— e.g. Snowflake / Discord / Twitter IDs, or MySQL BIGINT UNSIGNED keys up to
18446744073709551615 — are preserved exactly, as a string. LOOK has no
unsigned-64/bignum value type, so decoding such an ID to a float would silently lose precision
(…809 → …808); keeping the original text as a string carries the exact
identity losslessly — safe to compare, look up, and bind as a DB parameter (MySQL accepts a numeric
string literal for BIGINT UNSIGNED).
Two consequences to be aware of: (1) Do not do arithmetic on such an ID — you almost
never need to, and string→number coercion of an out-of-range value yields 0.
(2) Round-trip changes the type: json::decode then json::encode
re-emits the value as a JSON string ("9223372036854775809"), not a bare number. This matters
if you proxy/forward JSON verbatim to a strict downstream API.
session::
Critical rule in FastCGI: session::start() must be called inside a route callback — if called in global scope (outside route()) it silently fails. The reason: during look-fcgi's setup phase there is no HTTP context (and therefore no response to send a Set-Cookie header). Once a session is broken, a hot reload is required.
# ❌ WRONG — no HTTP context in the setup phase, Set-Cookie isn't sent
session::start()
route("GET", "/", function() { /* ... */ })
# ✅ CORRECT — inside every route callback that uses the session
route("POST", "/login", function() {
session::start() # ← first line
# ...
})
| Function | Description |
|---|---|
| session::start() | Start the session — sets a cookie, must be called inside a route callback |
| session::get("k") | Read a session value |
| session::set("k","v") | Write a session value |
| session::has("k") | Does the key exist? |
| session::destroy() | Delete the session |
Multi-Server / Load Balancer — Redis Session
Your LOOK code doesn't change. The driver is selected via env var only:
# add to the systemd service file
Environment=LOOK_SESSION_DRIVER=redis
Environment=LOOK_REDIS_URL=redis://127.0.0.1:6379/0
Environment=LOOK_SESSION_TTL=3600
| Env Var | Default | Description |
|---|---|---|
| LOOK_SESSION_DRIVER | file | file (single server) or redis (multi-server) |
| LOOK_REDIS_URL | redis://127.0.0.1:6379/0 | redis://[:pass@]host:port[/db] format |
| LOOK_SESSION_TTL | 3600 | Session lifetime in seconds (Redis EXPIRE + file-compatible) |
| LOOK_SESSION_DIR | /tmp | Session directory for the file driver |
Zero external dependency. The Redis protocol (RESP2) is implemented in the LOOK core — no hiredis or other library needed. Code that runs on a single server moves instantly to multi-server by changing an env var.
# Login
route("POST", "/login", function() use ($conn) {
$body = request::json()
# ... validation ...
session::start()
session::set("admin_id", $admin["id"])
session::set("email", $admin["email"])
print(json::encode(["ok" => true]))
})
# Protection
function auth_check() {
session::start()
if (!session::has("admin_id")) {
response::error(401, "Unauthorized")
return false
}
return true
}
route("GET", "/admin/panel", function() {
if (!auth_check()) { return }
# ... admin page ...
})
db::
Connection
$conn = db::connect("mysql://user:password@host/database")
$conn = db::connect("mysql://root:@127.0.0.1/mydb")
# Optional config — timeout and reconnect
$conn = db::connect("mysql://root:@127.0.0.1/mydb", [
"timeout" => 5000, # ms
"reconnect" => 3 # retry count
])
# with .env (standard)
$conn = db::connect(
"mysql://" . env("DB_USER", "root") . ":" . env("DB_PASS", "")
. "@" . env("DB_HOST", "127.0.0.1") . "/" . env("DB_NAME", "mydb")
)
Query — db::query (SELECT)
# Always returns an array (may be empty)
$rows = db::query($conn, "SELECT * FROM products", [])
$rows = db::query($conn, "SELECT * FROM products WHERE id=?", [$id])
$rows = db::query($conn, "SELECT * FROM p WHERE company_id=? AND active=?", [$fid, 1])
if (count($rows) == 0) {
json_error("Not found", 404)
return
}
$row = $rows[0]
$price = $row["price"] # DECIMAL column → already float
$qty = $row["qty"] # INT column → already int
$total = $price * $qty # works — no conversion needed
Automatic type conversion: LOOK reads MySQL schema types from the wire protocol. INT / BIGINT columns come back as LOOK int, FLOAT / DECIMAL columns as LOOK float. Manual int() / float() is now only needed for user input (URL parameters, form data).
Type Mapping Table
| MySQL Type | Examples | LOOK Type |
|---|---|---|
| INT, TINYINT, BIGINT, MEDIUMINT, SMALLINT, YEAR | id, qty, active, year | int |
| FLOAT, DOUBLE, DECIMAL | price, rate, tax_rate | float |
| VARCHAR, TEXT, CHAR, ENUM | name, title, slug, status | string |
| DATE, DATETIME, TIMESTAMP | created_at, date, updated | string (in "2026-06-08" format) |
Command — db::exec (INSERT/UPDATE/DELETE)
db::exec($conn, "INSERT INTO log (message, date) VALUES (?,NOW())", [$message])
db::exec($conn, "UPDATE products SET price=? WHERE id=?", [$price, $id])
db::exec($conn, "DELETE FROM products WHERE id=?", [$id])
Helper Functions
| Function | Description |
|---|---|
| db::last_id($conn) | Auto-increment ID of the last INSERT |
| db::affected($conn) | Number of rows affected by the last UPDATE/DELETE |
| db::col($conn, $sql, $params) | Returns the first column of the first row (scalar) |
| db::escape($conn, $str) | Escapes a string for SQL (prefer parameterized queries) |
| db::close($conn) | Close the connection |
| db::begin($conn) | Start a transaction (MySQL / SQLite / PostgreSQL) |
| db::commit($conn) | Commit the transaction |
| db::rollback($conn) | Roll back the transaction |
| db::transaction($conn, fn) | Automatic commit/rollback wrapper — rollback on exception, commit on success |
db::begin / commit / rollback — Transaction
db::begin($conn)
try {
db::exec($conn, "INSERT INTO orders (user_id, total) VALUES (?,?)", [$uid, $total])
$order_id = db::last_id($conn)
db::exec($conn, "UPDATE stock SET qty=qty-1 WHERE product_id=?", [$product_id])
db::commit($conn)
print(json::encode(["ok" => true, "id" => $order_id]))
} catch ($e) {
db::rollback($conn)
response::status(500)
print(json::encode(["error" => error::message($e)]))
}
db::transaction — Automatic Wrapper
# db::transaction — runs the closure between BEGIN/COMMIT, ROLLBACK on exception
$result = db::transaction($conn, function() use ($conn, $uid, $total) {
db::exec($conn, "INSERT INTO orders (user_id, total) VALUES (?,?)", [$uid, $total])
return db::last_id($conn)
})
# if an exception is thrown, rollback happens automatically and the exception propagates
db::exec($conn, "INSERT INTO products (name) VALUES (?)", [$name])
$new_id = db::last_id($conn) # comes back as int
$total = db::col($conn, "SELECT COUNT(*) FROM products WHERE company_id=?", [$fid])
# COUNT(*) → INT column → already int
db::query never returns null. An empty result = an empty array []. if ($rows == null) is never true. Always check with count($rows) == 0.
env() / config()
.env File
# C:\xampp\htdocs\.env
APP_ENV=development
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=mydb
DB_USER=root
DB_PASS=
LOG_DIR=C:/xampp/htdocs/logs
Using env()
$host = env("DB_HOST", "127.0.0.1") # second arg = default
$environment = env("APP_ENV") # "development"
Using config()
# for config.ini or a similar file
$limit = config("pagination.per_page") # dot-notation
LOG_DIR must be an absolute path. A relative path doesn't work in CGI mode — always use a full path like C:/xampp/....
log::
| Function | Level | Use |
|---|---|---|
| log::info("msg") | INFO | Normal-flow information |
| log::error("msg") | ERROR | Error conditions |
| log::warn("msg") | WARN | Warnings |
| log::debug("msg") | DEBUG | During development |
| log::query($sql, $ms) | QUERY | Slow-query logging |
| log::memory("msg") | MEMORY | Memory-usage logging |
| log::configure($dir, $verbose, $level) | — | Change log settings |
log::info("User logged in: {$email}")
log::error("DB connection error: {$e}")
log::warn("High memory usage")
log::query($sql, 1250) # query that took 1250ms
Logs are written to daily files in LOG_DIR (look-2026-06-08.log). On Windows they're concurrent-safe via FILE_SHARE_WRITE.
look::
Loaded with use look;. The language's own tooling, callable from LOOK code.
| Function | Description |
|---|---|
look::check($source) | Syntax + undefined-call check of LOOK source (in-process, does NOT run the code). Same engine as lk --check. |
look::check() returns an assoc array. It lexes, parses, and scans for undefined bare calls — no execution, no side effects, safe on untrusted input.
| Field | Type | Description |
|---|---|---|
ok | bool | true if valid; false on error |
line | int | Error line (0 when ok) |
col | int | Error column |
msg | string | "OK" or the error message |
use look;
$r = look::check($user_code)
if ($r["ok"]) {
print("Gecerli LOOK kodu")
} else {
print("Hata: " . $r["line"] . ":" . $r["col"] . " " . $r["msg"])
}
LLM tabanlı araçlar (ör. LOOKY) için ideal: üretilen veya kullanıcıdan gelen LOOK kodunu, alt-süreç çalıştırmadan doğrular — gerçek derleyici hatasını (satır/sütun) döndürür.
runtime::
Runtime statistics. No use required — always active.
| Function | Description |
|---|---|
runtime::stats() | Returns the interpreter state as an assoc array |
runtime::gc() | Trigger garbage collection — returns "ok" |
stats() Output
| Field | Type | Description |
|---|---|---|
uptime_sec | int | Seconds elapsed since process start |
request_count | int | Total requests handled by this process |
route_count | int | Number of registered routes |
working_mb | float | Working Set memory usage (MB) |
private_mb | float | Private memory usage (MB) |
# /monitor endpoint — protect with auth in production
route("GET", "/monitor", function() {
print(json::encode(runtime::stats()))
})
# Memory monitoring under load
$stats = runtime::stats()
if ($stats["working_mb"] > 500) {
log::warn("High memory: {$stats[\"working_mb\"]}MB")
runtime::gc()
}
use math
| Function | Description | Example |
|---|---|---|
| math::sqrt($n) | Square root | sqrt(144) → 12 |
| math::pow($b, $e) | Exponentiation | pow(2,10) → 1024 |
| math::abs($n) | Absolute value | abs(-5) → 5 |
| math::floor($n) | Round down | floor(3.9) → 3 |
| math::ceil($n) | Round up | ceil(3.1) → 4 |
| math::round($n) | Round | round(3.5) → 4 |
| math::max($a, $b) | The larger of two values | max(3,7) → 7 |
| math::max($a, $b, $c, ...) | Multiple arguments — the largest | max(3,7,2,9) → 9 |
| math::max([$arr]) | The largest in an array | max([5,3,8]) → 8 |
| math::min($a, $b) | The smaller of two values | min(3,7) → 3 |
| math::min($a, $b, $c, ...) | Multiple arguments — the smallest | min(3,7,2,9) → 2 |
| math::min([$arr]) | The smallest in an array | min([5,3,8]) → 3 |
| math::pi() | The π constant | 3.14159... |
| math::random($min,$max) | Random integer | random(1,100) |
| math::sin($r) | Sine (radians) | |
| math::cos($r) | Cosine | |
| math::tan($r) | Tangent | |
| math::log($n) | Natural logarithm |
use math
$area = math::pi() * math::pow($r, 2)
$n = math::random(1, 6) # rolling a die
use string
| Function | Description |
|---|---|
| string::upper($s) | Uppercase |
| string::lower($s) | Lowercase |
| string::trim($s) | Strip leading and trailing whitespace |
| string::ltrim($s) | Strip leading whitespace |
| string::rtrim($s) | Strip trailing whitespace |
| string::replace($s,$search,$with) | Replace |
| string::contains($s,$search) | Does it contain? (bool) |
| string::substr($s,$start,$len) | Substring ($len optional) |
| string::split($s,$sep) | Split into an array |
| string::join($arr,$sep) | Join an array |
| string::reverse($s) | Reverse |
| string::repeat($s,$n) | Repeat |
| string::index_of($s,$search) | First position (-1 = not found) |
| string::starts_with($s,$pre) | Does it start with? (bool) |
| string::ends_with($s,$suf) | Does it end with? (bool) |
| string::len($s) | Number of characters (alias: len) |
| string::slugify($s) | URL-friendly slug — spaces → -, special chars removed |
| string::pad_left($s,$len,$pad) | Pad left — adds $pad until it's $len characters total |
| string::pad_right($s,$len,$pad) | Pad right — adds $pad until it's $len characters total |
| string::random($n) | Random alphanumeric string of length $n (default 8) |
| string::format($fmt, ...) | printf-like formatting — supports %s %d %f %x |
| string::regex_match($s, $pat) | Regex match → bool |
| string::regex_replace($s, $pat, $rep) | Replace with a regex → string |
| string::regex_match_all($s, $pat) | Returns all matches → [[full, group1, ...], ...] |
UTF-8 aware. len, substr, reverse operate on Unicode characters (code points), not bytes — so string::len("İstanbul") is 8 and substr/reverse never split a multi-byte character into invalid UTF-8. upper/lower are locale-independent (like most languages: i↔I); Turkish letters (ç, ğ, ö, ş, ü, ı, İ) map to their standard Unicode counterparts.
use string
$slug = string::lower(string::replace($name, " ", "-"))
$parts = string::split("a,b,c,d", ",") # ["a","b","c","d"]
$joined = string::join($parts, " | ") # "a | b | c | d"
$clean = string::trim($input)
$length = string::len("Hello") # 5
# string::format — printf-like
$message = string::format("Name: %s, Age: %d, Price: %.2f USD", $name, $age, $price)
# "Name: Ali, Age: 30, Price: 45.68 USD"
$hex = string::format("0x%x", 255) # "0xff"
$pad = string::format("%05d", 42) # "00042"
# string::regex_match — regex match test
if (string::regex_match($email, "[a-z0-9._%+\\-]+@[a-z0-9.\\-]+\\.[a-z]{2,}")) {
# valid email
}
$digits_only = string::regex_match($v, "^[0-9]+$") # true/false
# string::regex_replace — pattern replacement
$slug = string::regex_replace($title, "[^a-z0-9]+", "-")
$clean = string::regex_replace($html, "<[^>]+>", "") # strip tags
$masked = string::regex_replace($phone, "\\d{3}$", "***") # mask the last 3 digits
# string::regex_match_all — all matches
$emails = string::regex_match_all($text, "[a-z]+@[a-z]+\\.[a-z]+")
foreach ($emails as $m) { print($m[0]) } # $m[0] = full match
# Group capture
$dates = string::regex_match_all($text, "(\\d{4})-(\\d{2})-(\\d{2})")
foreach ($dates as $t) {
print($t[1] . "/" . $t[2] . "/" . $t[3]) # year/month/day
}
use type
| Function | Description |
|---|---|
| type::of($v) | Returns the type name: "int", "float", "string", "bool", "null", "array", "function" |
| type::is_int($v) | bool |
| type::is_float($v) | bool |
| type::is_string($v) | bool |
| type::is_bool($v) | bool |
| type::is_null($v) | bool |
| type::is_array($v) | bool |
| type::is_function($v) | bool — is it a closure or named function? |
| type::to_int($v) | Convert to int |
| type::to_float($v) | Convert to float |
| type::to_string($v) | Convert to string |
| type::to_bool($v) | Convert to bool |
use type
print(type::of(42)) # "int"
print(type::of("hello")) # "string"
print(type::of([])) # "array"
print(type::is_string($v)) # true / false
use array
| Function | Description |
|---|---|
| array::sort($arr) | Sort (returns a new array) |
| array::filter($arr, $fn) | Filter |
| array::map($arr, $fn) | Transform |
| array::reduce($arr, $fn, $init) | Reduce |
| array::slice($arr, $start, $len) | Sub-array |
| array::unique($arr) | List of distinct elements |
| array::reverse($arr) | Reverse |
| array::contains($arr, $v) | Does the element exist? (bool) |
| array::keys($arr) | Get the keys (for an assoc array) |
| array::values($arr) | Get the values (for an assoc array) |
| array::find($arr, $fn) | Returns the first matching element, or null if none |
| array::any($arr, $fn) | true if at least one element satisfies the condition |
| array::all($arr, $fn) | true if all elements satisfy the condition |
| array::flatten($arr [,$depth]) | Flatten a nested array — default is infinite depth |
| array::chunk($arr, $n) | Split the array into chunks of $n → returns an array of arrays |
| array::zip($arr1, $arr2, ...) | Pair arrays by position → [[a1,b1],[a2,b2],...] |
| Function | Description |
|---|---|
| array::push($arr, $v) | Append an element, returns a new array |
| array::pop($arr) | Returns the last element of the array |
| array::set($arr, $key, $val) | Add/update a key in an assoc array → returns a new array (immutable) |
| array::new_assoc() | Create an empty assoc array |
| push($arr, $v) | Global built-in — no use array needed |
| pop($arr) | Global built-in — no use array needed |
| count($arr) | Number of elements (alias: len) |
use array
$numbers = [5, 2, 8, 1, 9, 3]
$sorted = array::sort($numbers) # [1,2,3,5,8,9]
$evens = array::filter($numbers, function($x) {
return $x % 2 == 0
}) # [2,8]
$squares = array::map($numbers, function($x) {
return $x * $x
}) # [25,4,64,1,81,9]
$total = array::reduce($numbers, function($acc, $x) {
return $acc + $x
}, 0) # 28
$distinct = array::unique([1,2,2,3,3,3]) # [1,2,3]
$keys = array::keys(["a"=>1, "b"=>2]) # ["a","b"]
# find / any / all
$products = [
["name" => "Pizza", "price" => 120],
["name" => "Burger", "price" => 80],
["name" => "Salad", "price" => 45]
]
$expensive = array::find($products, function($u) { return $u["price"] > 100 })
print($expensive["name"]) # Pizza
$has_cheap = array::any($products, function($u) { return $u["price"] < 50 })
print($has_cheap ? "has cheap" : "no cheap") # has cheap
$all_positive = array::all($products, function($u) { return $u["price"] > 0 })
print($all_positive) # true
# find returns null if nothing matches — use a ternary to be safe
$result = array::find($products, function($u) { return $u["price"] > 500 })
print($result != null ? $result["name"] : "not found") # not found
# array::flatten — flatten a nested array
$nested = [1, [2, 3], [4, [5, 6]]]
$flat = array::flatten($nested) # [1,2,3,4,5,6]
$one_level = array::flatten($nested, 1) # [1,2,3,4,[5,6]] — depth 1
# array::chunk — split into chunks (ideal for pagination)
$ids = [1,2,3,4,5,6,7]
$chunks = array::chunk($ids, 3) # [[1,2,3],[4,5,6],[7]]
foreach ($chunks as $group) {
# Run a batched DB query for each group
$in_str = string::join($group, ",")
$rows = db::query($conn, "SELECT * FROM products WHERE id IN (?)", [$in_str])
}
# array::zip — pair two arrays by position
$names = ["Ali", "Veli", "Ayşe"]
$ages = [30, 25, 28]
$zipped = array::zip($names, $ages) # [["Ali",30],["Veli",25],["Ayşe",28]]
foreach ($zipped as $pair) {
print($pair[0] . ": " . $pair[1]) # Ali: 30
}
# zip + map combination — a numbered list
$order = [1, 2, 3]
$ordered = array::zip($order, $names) # [[1,"Ali"],[2,"Veli"],[3,"Ayşe"]]
use http
A zero-dependency HTTP/HTTPS client — uses the system OpenSSL on Linux and Schannel (WinSSL) on Windows. No libcurl required.
| Function | Description |
|---|---|
| http::get($url [,$headers [,$opts]]) | HTTP GET request |
| http::post($url, $body [,$headers [,$opts]]) | POST — form-encoded body |
| http::post_json($url, $data [,$headers [,$opts]]) | POST — $data is auto-converted to JSON, Content-Type: application/json is added |
| http::put($url, $body [,$headers [,$opts]]) | HTTP PUT |
| http::patch($url, $body [,$headers [,$opts]]) | HTTP PATCH |
| http::delete($url [,$headers [,$opts]]) | HTTP DELETE |
| http::stream($method, $url, $body, $headers, $callback [,$opts]) | Streaming — body chunks (chunked-decoded) delivered to $callback($chunk) as they arrive. For SSE / live token streams (e.g. LLM APIs). |
$headers — assoc array (optional): ["Authorization" => "Bearer token"]
$opts — assoc array (optional): ["timeout" => 5000] (ms, default 10000)
http::stream — live streaming
http::stream() does not buffer the whole response; instead it calls your $callback with each body chunk as it arrives. Pairs naturally with route("SSE", ...) and channel() — the building blocks for forwarding an upstream token stream (e.g. a Claude/LLM SSE response) straight to your client. Returns a response assoc (status, headers, error); the body arrives via the callback.
http::stream("POST", $url, $json_body, $headers, function($chunk) {
# her parca geldikce — SSE cikisina / kanala yaz
print($chunk)
}, ["timeout" => 60000])
Resmi ai modülü (look module install github.com/codlook/look-modules/ai) bunun üstüne kuruludur — ai_stream() ile Claude API'den token token yanıt alırsın.
Return value — always an assoc array:
| Field | Type | Description |
|---|---|---|
| status | int | HTTP status code (200, 404, 500, ...) |
| body | string | Response body |
| headers | assoc | Response headers (lowercase keys) |
| error | string | null | Network error: "timeout", "connection failed", etc. HTTP errors (404, etc.) are not here but in status |
use http
# Basic GET
$resp = http::get("https://api.example.com/users")
if ($resp["status"] == 200) {
$data = json::decode($resp["body"])
# use $data
} else {
log::error("API error: " . $resp["status"])
}
# POST JSON — data is auto-encoded
$resp = http::post_json("https://api.example.com/order", [
"product_id" => 42,
"qty" => 3,
"note" => "mild spicy"
])
# Authorization header
$resp = http::get("https://api.example.com/profile", [
"Authorization" => "Bearer {$token}"
])
# With a timeout — 5 seconds
$resp = http::get("https://slow-service.com/data", [], ["timeout" => 5000])
if ($resp["error"] != null) {
log::error("Network error: " . $resp["error"])
response::status(503)
print(json::encode(["error" => "Connection timeout"]))
return
}
# DELETE
$resp = http::delete("https://api.example.com/record/{$id}", [
"Authorization" => "Bearer {$token}"
])
# PUT update
$resp = http::put("https://api.example.com/record/{$id}",
json::encode(["status" => "active"]),
["Content-Type" => "application/json",
"Authorization" => "Bearer {$token}"]
)
# Concurrent calls with parallel() — fan-out pattern
$result = channel(3)
parallel(function() use ($result) {
use http
send($result, http::get("https://service-a.com/data"))
})
parallel(function() use ($result) {
use http
send($result, http::get("https://service-b.com/data"))
})
$a = receive($result)
$b = receive($result)
# $a and $b arrived at the same time — total latency is max(service-a, service-b)
use auth
Secure password hashing with PBKDF2-SHA256 — zero dependency.
| Function | Description |
|---|---|
| auth::hash("password") | Hash a password (a different salt each call) |
| auth::verify("password", $hash) | Verify a password (bool) |
use auth
# Signup — hash the password
$hash = auth::hash("user_password")
# → "pbkdf2$sha256$100000$<salt_b64>$<hash_b64>"
db::exec($conn, "INSERT INTO admins (email,password) VALUES (?,?)", [$email, $hash])
# Login — verify
$rows = db::query($conn, "SELECT password FROM admins WHERE email=?", [$email])
if (count($rows) == 0) { # no such user }
if (!auth::verify($password, $rows[0]["password"])) { # wrong password }
use crypto
SHA-256, HMAC-SHA256, Base64, UUID, secure random generation — zero dependency. The foundation for JWT and payment integrations.
| Function | Description | Returns |
|---|---|---|
| crypto::sha256($data) | SHA-256 digest | hex string (64 characters) |
| crypto::hmac_sha256($data, $key) | HMAC-SHA256 signature | hex string |
| crypto::hmac_sha256_raw($data, $key) | HMAC-SHA256 — raw bytes (for JWT signing) | binary string |
| crypto::base64_encode($s) | Standard Base64 encode | string |
| crypto::base64_decode($s) | Standard Base64 decode | string |
| crypto::base64url_encode($s) | URL-safe Base64, no padding (JWT) | string |
| crypto::base64url_decode($s) | URL-safe Base64 decode | string |
| crypto::hex_encode($s) | Hex encode | string |
| crypto::hex_decode($hex) | Hex decode | string |
| crypto::uuid() | Generate a UUID v4 (RFC 4122) | string |
| crypto::random_bytes($n) | n cryptographic random bytes | hex string |
| crypto::random_string($n) | n bytes of entropy — URL-safe Base64 | string |
| crypto::constant_compare($a, $b) | Timing-safe string comparison | bool |
| crypto::rs256_sign($data, $pem_key) | Sign with RSA-SHA256 (PEM private key) | binary string |
| crypto::rs256_sign_b64url($data, $pem_key) | Sign with RSA-SHA256 → base64url (for JWT RS256 signing) | string |
| crypto::rs256_verify($data, $sig, $pem_key) | Verify an RSA-SHA256 signature (PEM public key) | bool |
RS256 (JWT) signing
use crypto
# Generate a JWT RS256 signature with a PEM private key
$pem = file::read("/secrets/rsa_private.pem")
$header = crypto::base64url_encode(json::encode(["alg" => "RS256", "typ" => "JWT"]))
$payload = crypto::base64url_encode(json::encode(["sub" => $user_id, "exp" => $exp]))
$signing_input = $header . "." . $payload
$sig = crypto::rs256_sign_b64url($signing_input, $pem)
$token = $signing_input . "." . $sig
# Verification (with the public key)
$pub = file::read("/secrets/rsa_public.pem")
$parts = string::split($token, ".")
$ok = crypto::rs256_verify($parts[0] . "." . $parts[1], $parts[2], $pub)
Verifying a webhook signature
use crypto
# Verify an incoming webhook signature (e.g. GitHub, Stripe)
$payload = request::body()
$given = request::header("X-Hub-Signature-256")
$expected = "sha256=" . crypto::hmac_sha256($payload, $WEBHOOK_SECRET)
if (!crypto::constant_compare($given, $expected)) {
response::json(["error" => "Invalid signature"], 401)
return
}
Secure token generation
use crypto
# Password-reset / email-verification token
$token = crypto::random_string(32) # 32 bytes of entropy — URL-safe
$id = crypto::uuid() # UUID v4
db::exec($conn, "INSERT INTO tokens (id,token,user_id) VALUES (?,?,?)",
[$id, $token, $user_id])
Using it with the JWT module
# the jwt module is built on top of crypto::
use "pkg/jwt/jwt.lk"
$token = jwt_sign(["user_id" => 42], "secret", ["exp" => 3600])
$payload = jwt_verify($token, "secret")
if ($payload == null) {
response::status(401)
return
}
print($payload["user_id"]) # → 42
JWT — pkg/jwt
LOOK's official JWT package. Built on top of the crypto:: module — zero extra dependency. HS256 (HMAC-SHA256) signing, verification, payload reading. Installed with look install.
Installation
look install github.com/Codlook/look-packages
After installation it extracts to pkg/Codlook/look-packages/jwt/jwt.lk. For a shortcut you can use a pkg/jwt/ symlink folder in your project root, or the direct path.
Functions
| Function | Description | Returns |
|---|---|---|
| jwt_sign($payload, $secret [, $opts]) | Generate an HS256 JWT token | string (token) |
| jwt_verify($token, $secret) | Verify the signature + check expiration | assoc (payload) or null |
| jwt_decode($token) | Read the payload without verifying the signature (debug) | assoc or null |
jwt_sign — Options ($opts)
| Field | Type | Description |
|---|---|---|
| exp | int | Validity in seconds (e.g. 3600 = 1 hour) |
| iss | string | Issuer — who generated the token |
| aud | string | Audience — who the token is for |
Basic Usage
use "pkg/jwt/jwt.lk"
# Generate a token — valid for 1 hour
$token = jwt_sign(
["user_id" => 42, "role" => "admin"],
env("JWT_SECRET", "secret"),
["exp" => 3600]
)
# Verify a token — null: invalid signature or expired
$payload = jwt_verify($token, env("JWT_SECRET", "secret"))
if ($payload == null) {
response::json(["error" => "Unauthorized"], 401)
return
}
print("User: " . $payload["user_id"]) # → 42
API Protection — Middleware Pattern
use "pkg/jwt/jwt.lk"
function auth_required() {
$header = request::header("Authorization") ?? ""
if (string::starts_with($header, "Bearer ")) {
$token = string::substr($header, 7)
} else {
$token = request::header("X-Token") ?? ""
}
$payload = jwt_verify($token, env("JWT_SECRET", ""))
if ($payload == null) {
response::json(["error" => "Invalid token"], 401)
return null
}
return $payload
}
route("GET", "/api/profile", function() use ($conn) {
$user = auth_required()
if ($user == null) { return }
$rows = db::query($conn, "SELECT * FROM users WHERE id=?", [$user["user_id"]])
print(json::encode($rows[0]))
})
route("POST", "/api/login", function() use ($conn) {
$email = request::post("email") ?? ""
$password = request::post("password") ?? ""
$rows = db::query($conn, "SELECT * FROM users WHERE email=?", [$email])
if (count($rows) == 0 || !auth::verify($password, $rows[0]["password_hash"])) {
response::json(["error" => "Invalid credentials"], 401)
return
}
$token = jwt_sign(["user_id" => $rows[0]["id"]], env("JWT_SECRET", ""), ["exp" => 86400])
print(json::encode(["ok" => true, "token" => $token]))
})
Official Package Repository
JWT and other official packages live in the github.com/Codlook/look-packages repository.
# Install the official Codlook package repository (jwt, stripe-wrapper, etc.)
look install github.com/Codlook/look-packages
# Then use it:
use "pkg/Codlook/look-packages/jwt/jwt.lk"
# Or the shortcut (as an extra line in look.lock):
use "pkg/jwt/jwt.lk" # pkg/jwt/ → pkg/Codlook/look-packages/jwt/ symlink
use validator
| Function | Description | Returns |
|---|---|---|
| validator::check($data, $rules) | Batch validation — validates all fields at once | ["ok" => bool, "errors" => assoc] |
Rule strings: "required", "email", "integer", "numeric", "min:N", "max:N", "in:a,b,c" — these are rule names passed to check(), not independently callable functions.
validator::check — Batch Validation
use validator
$body = request::json()
$result = validator::check($body, [
"email" => ["required", "email"],
"password" => ["required", "min:6"],
"age" => ["integer", "min:0", "max:120"],
"type" => ["in:percent,fixed"]
])
if (!$result["ok"]) {
response::json(["ok" => false, "errors" => $result["errors"]], 400)
return
}
# $result["ok"] == true → all fields valid
use html
| Function | Description |
|---|---|
| html::escape($s) | Escape HTML special characters (< > & " ') |
| html::attr($s) | Escape for use in an HTML attribute value |
| html::strip($s) | Remove HTML tags |
use html
$clean = html::escape($user_input) # XSS protection
$attr = html::attr($title)
$plain = html::strip($html_content)
use file
File read/write and secure upload. Loaded with use file;.
File Operations
| Function | Description |
|---|---|
| file::read($path) | Read a file → string |
| file::put($path, $content) | Write a file (overwrites) → bool |
| file::append($path, $content) | Append to a file → bool |
| file::exists($path) | Does the file exist? → bool |
| file::remove($path) | Delete a file → bool |
| file::size($path) | Size (bytes) → int |
| file::upload_dir() | Upload root directory (UPLOAD_DIR env) → string |
| file::store($file, $subdir) | Move an uploaded file to permanent storage → assoc array |
use file
# Write / read / append
file::put("./log.txt", "Started\n")
file::append("./log.txt", "Done\n")
$content = file::read("./log.txt")
print($content)
# Check / delete
if (file::exists("./cache.json")) {
print(file::size("./cache.json"))
file::remove("./cache.json")
}
LOOK_FILE_ROOT — File Sandbox
Secure by default. file:: operations are sandboxed to a directory subtree; path traversal (../) and absolute paths outside it throw an error. The root is chosen as follows:
LOOK_FILE_ROOT | Behaviour |
|---|---|
| unset (default) | Restricted to the current working directory — safe out of the box. |
/path/to/dir | Restricted to that directory (widen or relocate the sandbox). |
* | Unrestricted — explicit opt-out. Use only for trusted server-side scripts. |
Internal modules (sessions, cache, jobs) do not go through file:: and are unaffected by this sandbox.
# in the systemd service file:
Environment=LOOK_FILE_ROOT=/var/www/vhosts/domain.com/httpdocs
# Allowed — under LOOK_FILE_ROOT
file::read("/var/www/vhosts/domain.com/httpdocs/data/config.json")
# These throw an error — trying to escape LOOK_FILE_ROOT
file::read("/etc/passwd")
file::read("../../etc/passwd")
Automatic in the Plesk extension: enable.sh adds LOOK_FILE_ROOT to the systemd service file — confining it to the domain's httpdocs/ directory.
Secure Upload
request::file() is in core — no use needed. Magic-byte checking, MIME validation, SHA-256 hashing and a random file name are applied automatically. It can't be disabled.
⚠️ Serving mode matters. multipart/form-data is parsed on the
FastCGI path (lk-fcgi behind Apache/nginx with
mod_proxy_fcgi). It is not parsed when you run the built-in HTTP
server with --mode http — there request::file() returns
null even though the browser sent the file. If you deploy with
--mode http, upload the file as base64 inside a JSON body
(request::json()) instead, or put it behind FastCGI.
| Check | Description |
|---|---|
| Magic byte | Detects the real type from the first bytes of the file content — the extension isn't trusted |
| MIME validation | Checked against the allow_mime list |
| Size limit | Defaults to 1MB if unspecified, deliberately raised |
| Random name | The user's file name is never kept — a SHA-256-based hex name |
| SVG permission | SVG is rejected unless allow_svg: true is explicitly set |
| Web root protection | file::store() refuses to write under the web root |
use file
# Basic upload
$file = request::file("avatar", [
"max_size" => 2097152,
"allow_mime" => ["image/jpeg", "image/png", "image/webp"]
])
# $file["path"] → temporary file path
# $file["mime"] → validated MIME
# $file["size"] → bytes
# $file["sha256"] → SHA-256 hex hash
# Move to permanent storage
$record = file::store($file, "avatars")
# $record["path"] → /storage/uploads/avatars/abc123.jpg
# $record["url"] → https://site.com/uploads/avatars/abc123.jpg
# $record["sha256"] → same hash (deduplication)
# SVG — a separate permission is required
$logo = request::file("logo", [
"max_size" => 512000,
"allow_mime" => ["image/svg+xml"],
"allow_svg" => true
])
# Full upload route example
use file
route("POST", "/profile/photo", function() use ($conn) {
if (!db_check($conn)) { return }
try {
$file = request::file("avatar", [
"max_size" => 2097152,
"allow_mime" => ["image/jpeg", "image/png"]
])
$record = file::store($file, "avatars")
db::exec($conn,
"UPDATE users SET avatar=? WHERE id=?",
[$record["url"], session::get("user_id")]
)
print(json::encode(["ok" => true, "url" => $record["url"]]))
} catch ($e) {
response::error(400, $e)
}
})
.env settings:
UPLOAD_DIR=/var/www/storage/uploads
UPLOAD_URL=https://site.com/uploads
The upload directory must be outside the web root. file::store() checks this automatically.
use date
Date and time operations. Loaded with use date;. All dates are in ISO 8601 format — "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS".
| Function | Description | Example |
|---|---|---|
| date::now() | Current date + time → string | "2026-06-09 14:32:00" |
| date::today() | Today's date → string | "2026-06-09" |
| date::timestamp() | Unix epoch seconds → int | 1749470520 |
| date::format($date, $fmt) | Format a date → string | "09.06.2026" |
| date::parse($str, $fmt) | Parse a string into a date → string | "2026-06-09" |
| date::add($date, $n, $unit) | Add → string | add 7 days |
| date::sub($date, $n, $unit) | Subtract → string | subtract 1 month |
| date::diff($t1, $t2, $unit) | Difference → int | 205 days |
| date::weekday($date) | Day of the week → int (1=Mon, 7=Sun) | 2 |
| date::week($date) | ISO week number → int | 24 |
| date::is_valid($date) | Is it a valid date? → bool | false |
| date::from_timestamp($ts) | Unix → "YYYY-MM-DD HH:MM:SS" | "2026-06-09..." |
Format Tokens
| Token | Description | Example |
|---|---|---|
d | Day 01-31 | 09 |
j | Day 1-31 (no leading zero) | 9 |
m | Month 01-12 | 06 |
n | Month 1-12 (no leading zero) | 6 |
Y | 4-digit year | 2026 |
y | 2-digit year | 26 |
H | Hour 00-23 | 14 |
i | Minute 00-59 | 32 |
s | Second 00-59 | 00 |
D | Short day name | Tue |
l | Long day name | Tuesday |
M | Short month name | Jun |
F | Long month name | June |
N | ISO day of week (1=Mon, 7=Sun) | 2 |
t | Number of days in the month | 30 |
U | Unix timestamp | 1749470520 |
Units
For add / sub / diff: "second", "minute", "hour", "day", "week", "month", "year"
use date
# Basics
print(date::now()) # "2026-06-09 14:32:00"
print(date::today()) # "2026-06-09"
print(date::timestamp()) # 1749470520
# Format
print(date::format(date::today(), "d.m.Y")) # "09.06.2026"
print(date::format(date::now(), "l, d F Y H:i")) # "Tuesday, 09 June 2026 14:32"
# Parse — from a different format to ISO
$date = date::parse("09.06.2026", "d.m.Y") # "2026-06-09"
$date = date::parse("09/06/26", "d/m/y") # "2026-06-09"
# Add / Sub
date::add("2026-06-09", 7, "day") # "2026-06-16"
date::add("2026-06-09", 1, "month") # "2026-07-09"
date::sub("2026-06-09", 1, "year") # "2025-06-09"
# Diff
$day_diff = date::diff("2026-12-31", "2026-06-09", "day") # 205
# Validation
date::is_valid("2026-02-29") # false — 2026 is not a leap year
date::is_valid("2024-02-29") # true
# Real-world usage — compute days since signup
use date
$signup_date = $rows[0]["created_at"] # "2025-01-15 10:30:00" (from DB)
$days = date::diff(date::today(), $signup_date, "day")
print("Membership length: {$days} days")
# Orders in the last 30 days
$start = date::sub(date::today(), 30, "day")
$rows = db::query($conn,
"SELECT * FROM orders WHERE created_at >= ?",
[$start]
)
use template — Template Engine
Loaded with use template;. Renders HTML templates from a file or a string. It returns a string — you must use print(template::render(...)).
Critical: template::render() returns a string, it doesn't output directly. Always use it as print(template::render(...)). A bare template::render(...) won't work (dispatch-copy isolation).
API
| Function | Description |
|---|---|
| template::render($path, $data) | Load a template from a file, render with $data → string |
| template::render_string($tpl, $data) | Render an inline template string → string |
| template::escape($str) | HTML escape (for manual use) |
Directives
| Directive | Description |
|---|---|
| {$var} | HTML-escaped variable |
| {$obj.field} | Assoc/struct field access |
| {!$var} | Raw (no escaping) — for HTML content |
| {#if $var} … {/if} | Conditional block |
| {#if $var} … {#else} … {/if} | if/else |
| {#if $x == "y"} | Comparison: ==, !=, >, <, >=, <= |
| {#if !$var} | Not (falsy check) |
| {#each $list as $e} … {/each} | Array iteration |
| {#each $list as $e} … {#empty} … {/each} | Empty-list branch |
| {#extends "views/layout/base"} | Layout inheritance (at the top of the file) |
| {#block "content"} … {/block} | Overridable block |
| {#include "views/partials/nav"} | Partial include |
Basic Usage
use template
# Render from a file
$html = template::render("/app/views/blog/index", [
"title" => "Blog",
"posts" => $posts
])
print($html)
# Inline render
print(template::render_string("<h1>{$title}</h1>", ["title" => "Hello"]))
# The .html extension is added automatically — views/blog/index → views/blog/index.html
Layout Inheritance
<!-- views/layout/base.html -->
<!DOCTYPE html><html><body>
<header>...</header>
{#block "content"}{/block}
<footer>...</footer>
</body></html>
<!-- views/blog/index.html -->
{#extends "views/layout/base"}
{#block "content"}
<h1>{$title}</h1>
{#each $posts as $y}
<article>
<h2>{$y.title}</h2>
<p>{$y.summary}</p>
</article>
{#empty}
<p>No posts yet.</p>
{/each}
{/block}
Not allowed (parse error): {$function($arg)}, {$a + $b} — function calls and arithmetic expressions aren't supported inside a template. Do the computation in LOOK code and pass the result to the template.
use "file.lk" — File Module System
Lets you split large projects into multiple .lk files. Explicit-scope principle: only functions and constants are exported — $variables never leak.
Explicit scope: use "file.lk" exports only functions and constants — it doesn't share global state. $conn is never secretly visible; it's always passed as an explicit parameter. This fundamentally prevents the "where did this variable come from?" confusion in large apps.
3 Core Rules
| Rule | Behavior |
|---|---|
| Scope isolation | Only function and const are exported. $var variables aren't shared. |
| Circular include | a.lk → b.lk → a.lk → LookRuntimeError: Circular include |
| Top-level only | use "file.lk" only at global scope. Inside a function → LookParseError |
Syntax
# blog.lk — bootstrap (~43 lines)
use template
use "helpers.lk" # db_check(), admin_check(), slugify()
use "routes/public.lk" # register_public_routes($conn, $tpl)
use "routes/auth.lk" # register_auth_routes($conn, $tpl)
use "routes/admin.lk" # register_admin_routes($conn, $tpl)
$conn = db::connect("mysql://...")
$tpl = env("BLOG_VIEWS", "/app/views")
register_public_routes($conn, $tpl) # $conn is an explicit parameter — required
register_auth_routes($conn, $tpl)
register_admin_routes($conn, $tpl)
# routes/public.lk — $conn isn't visible, it arrives as a parameter
use template
function register_public_routes($conn, $tpl) {
route("GET", "/blog/", function() use ($conn, $tpl) {
if (!db_check($conn)) { return }
$posts = db::query($conn, "SELECT * FROM posts WHERE published=1", [])
print(template::render($tpl . "/blog/index", ["posts" => $posts]))
})
}
use "file.lk" vs use module;
use math; | use "file.lk" | |
|---|---|---|
| Source | C++ built-in | .lk source file |
| Exported | Module functions | Only function + const |
| $var sharing | — | No (isolated env) |
| Cycle protection | — | Yes (included_files_ set) |
| Top-level guard | — | Yes (LookParseError) |
FastCGI Warm Start
LOOK's most important performance feature. In FastCGI mode, index.lk is loaded, parsed and interpreted only once.
How It Works
# look-fcgi.exe --port 9000
First request arrives → setup phase:
1. index.lk is parsed → AST (kept in WarmApp::program)
2. set_setup_mode(true) → interpret()
- route() calls → registered in route_registry_
- db::connect() → $conn stays in interpreter globals_
- a DB error is caught via try/catch, $conn stays as null
3. set_setup_mode(false)
EVERY subsequent request → dispatch phase:
1. make_web_context(req) → REQUEST_URI, METHOD, headers, cookies
2. dispatch_routes() → pattern match → callback($param...)
3. $conn is ready → no new DB connection is opened
Hot Reload
When the index.lk file changes (mtime), FastCGI reloads automatically. No need to restart the server.
Critical Rules
WarmApp::program — the AST must stay alive for the life of the process. Closures hold pointers to AST nodes.
WarmApp::setup_out — the setup output stream must be a WarmApp member. If it's a local variable, a dangling pointer → crash.
Platform & DB Support
One-command install (release packages): download the package for your platform from Releases — all self-contained (binary embedded):
| Platform | Package | Install |
|---|---|---|
| Ubuntu / AlmaLinux / RHEL | look-lang-linux-1.0.0.zip | sudo bash install.sh |
| AlmaLinux / RHEL (dnf) | look-lang-1.0.0-1.el8.x86_64.rpm | dnf install look-lang-…rpm |
| Plesk (panel) | look-lang-plesk-1.0.0.zip | plesk bin extension --install … |
| Windows + XAMPP | look-lang-xampp-1.0.0.zip | .\install.ps1 (Administrator) |
Details: Windows Installer · Plesk Extension · platforms/linux/ubuntu/README.md
The table below shows every combination a developer will encounter when installing LOOK from scratch. ✅ Works = verified, ⚠️ Limited = works but with a step to watch, ❌ None = unsupported.
Platform × Mode × DB Support
| Platform | Binary | FastCGI | CGI | MySQL | SQLite | PostgreSQL | Docs |
|---|---|---|---|---|---|---|---|
| Windows + XAMPP | look-fcgi.exe / look-cgi.exe | ✅ | ✅ | ✅ | ✅ | ✅ | xampp/setup.md |
| Ubuntu 22.04 / 24.04 + Apache | look-fcgi / look-cgi | ✅ | ✅ ¹ | ✅ | ✅ | ✅ | docs/ubuntu-deployment.md |
| AlmaLinux 8 + Apache | look-fcgi / look-cgi | ✅ | ✅ | ✅ | ✅ | ✅ | docs/plesk-apache-deployment.md |
| Plesk (AlmaLinux 8) | look-fcgi | ✅ Live ² | ⚠️ ³ | ✅ | ✅ | ✅ | docs/plesk-apache-deployment.md |
| Docker (CI / build) | — | ✅ ⁴ | ✅ ⁴ | ✅ | ✅ | ✅ | Below |
| CLI (look) | look / look.exe | — | — | — ⁵ | — ⁵ | — ⁵ | For language testing |
¹ On Ubuntu, CGI requires mpm_prefork — mpm_event + mod_cgid doesn't work.
² AlmaLinux 8.10, nginx→Apache→look-fcgi:9000, MariaDB.
³ Plesk has no CGI Action/ScriptAlias; standalone .lk scripts run via look-fcgi direct mode.
⁴ Docker images are for the build environment — Ubuntu/AlmaLinux Apache test containers are also available.
⁵ In the CLI, request::, db::, session:: return empty since they require an HTTP context.
Where Does the Binary Come From?
| Target Platform | Build Method | Output Directory | Command |
|---|---|---|---|
| Windows | Visual Studio + CMake | cpp/build/Release/ |
cmake --build . --config Release |
| Any Linux (Ubuntu·Debian·AlmaLinux·Rocky·RHEL 8·9·10+) | Docker — one portable static binary (build once, runs everywhere) | cpp/build-portable/ |
Below ↓ |
Windows — Build and Install
# 1. Build (in a Visual Studio developer environment)
cd cpp\build
cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build . --config Release
# 2. Copy the binaries
copy cpp\build\Release\look-fcgi.exe C:\xampp\cgi-bin\
copy cpp\build\Release\look-cgi.exe C:\xampp\cgi-bin\
# 3. Start FastCGI
Start-Process "C:\xampp\cgi-bin\look-fcgi.exe" -ArgumentList "--port 9000" -WindowStyle Hidden
Fastest — run the published image (no build)
# The official image is on Docker Hub. Your app lives in ./app, mounted at /app.
docker run -p 7400:7400 -v "$PWD/app:/app" codlook/look
# First run pulls ~43 MB, then serves http://localhost:7400/ (put index.lk in ./app).
Linux server — one portable binary for every distribution
Build once on AlmaLinux 8 (glibc 2.28 + statically linked OpenSSL). The result is a single binary that runs unchanged on Ubuntu, Debian, AlmaLinux, Rocky and RHEL (8·9·10 and newer) — no per-distribution builds, no glibc mismatch. This replaces the old separate Ubuntu/AlmaLinux build recipes.
# 1. Build the portable binary (static OpenSSL + static libstdc++)
docker build -t look-build -f cpp/Dockerfile.build cpp/
docker run --rm -v "$PWD/cpp:/look/cpp" -w /look/cpp look-build bash build-portable.sh
# Output: cpp/build-portable/{lk, lk-fcgi, lk-cgi} — glibc + libz only
# 2. Transfer to the server (scp strips the execute bit — chmod is required!)
scp cpp/build-portable/lk-fcgi root@server:/opt/look/lk-fcgi
scp cpp/build-portable/lk-cgi root@server:/opt/look/lk-cgi
chmod +x /opt/look/lk-fcgi /opt/look/lk-cgi
# 3a. Debian/Ubuntu Apache modules (order matters!)
sudo a2dismod mpm_event; sudo a2enmod mpm_prefork cgi rewrite proxy proxy_fcgi actions
sudo systemctl restart apache2
# 3b. systemd service (any distribution)
cat > /etc/systemd/system/look-fcgi.service << 'EOF'
[Unit]
Description=LOOK FastCGI
After=network.target mariadb.service
[Service]
Type=simple
ExecStart=/opt/look/lk-fcgi --port 9000
WorkingDirectory=/var/www/vhosts/domain.com/httpdocs
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload && systemctl enable look-fcgi && systemctl start look-fcgi
Plesk — .htaccess (AllowOverride FileInfo)
# /var/www/vhosts/domain.com/httpdocs/.htaccess
# NOTE: don't write Options +FollowSymLinks — Plesk blocks it (returns 500)
# NOTE: vhost_ssl.conf doesn't work — Plesk doesn't auto-include it
RewriteEngine On
RewriteRule ^index\.lk$ / [R=301,L]
RewriteCond %{REQUEST_URI} \.(html|htm|css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|txt|map|webp)$ [NC]
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^ - [L]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Standalone .lk scripts → look-fcgi direct mode (a route-less script)
RewriteCond %{REQUEST_URI} \.lk$ [NC]
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^(.+\.lk)$ fcgi://127.0.0.1:9000/$1 [P,QSA,L]
# Router
RewriteRule ^ fcgi://127.0.0.1:9000/index.lk [P,QSA,L]
DB Connection DSNs
| DB | DSN Format | Note |
|---|---|---|
| MySQL / MariaDB | mysql://user:password@127.0.0.1/dbname | Zero dependency, wire protocol |
| SQLite | sqlite://./data.db or sqlite://:memory: | File or in-memory |
| PostgreSQL | postgres://user:password@127.0.0.1/dbname | Protocol v3, MD5 auth |
| PostgreSQL (port) | postgres://user:password@host:5432/dbname | Specify the port |
Common Installation Problems
| Problem | Platform | Solution |
|---|---|---|
| 500 — Options not allowed here | Plesk | Remove Options +FollowSymLinks from .htaccess |
| Binary doesn't run (203/EXEC) | Linux | chmod +x look-fcgi — scp strips the execute bit |
| Binary won't run on another distro (GLIBC_2.xx not found) | Linux | Use the portable static binary (build-portable.sh, built on AlmaLinux 8) — one binary runs on all glibc ≥ 2.28 distros. No per-distro builds. |
| CGI doesn't work on Ubuntu | Ubuntu | a2dismod mpm_event → a2enmod mpm_prefork cgi |
| Session doesn't work / 401 | FastCGI | session::start() must be called inside a route callback, not at global scope |
| hello.lk → "Endpoint not found" | FastCGI | Old look-fcgi (no direct mode) — rebuild the binary |
| Rewrite doesn't work on XAMPP | Windows | The rules must be inside <Directory "C:/xampp/htdocs">, not at global scope |
| Can't connect to DB | All | DB_HOST=127.0.0.1 (TCP, not socket); hot reload with systemctl restart look-fcgi |
| No logs | All | LOG_DIR must be an absolute path; does the directory have write permission? |
Concurrent Runtime (Phase 12)
look-fcgi runs fully concurrently. Each HTTP request is handled on a separate worker thread; your LOOK code never changes. For more advanced concurrency, use parallel() + channel().
Two dispatch modes — worker pool (default) or fibers
Besides the worker pool, LOOK ships a fiber runtime: stackful green threads
parked on epoll (the Go netpoller model). It is opt-in:
LOOK_FIBER_DISPATCH=1 lk-fcgi --mode http --port 9000 --workers 4
Which one to use is a workload question, and the answer is measured, not guessed:
| Workload | Worker pool (default) | Fibers | Use |
|---|---|---|---|
| Fast route, no external I/O (c=50) | 11,506 req/s, p95 6 ms | 9,724 req/s, p95 8 ms | pool (~15 % faster, better tail) |
| Fast route (c=200) | 9,210 req/s, p95 28 ms | 8,097 req/s, p95 31 ms | pool |
| Slow external API (0.5 s), 1 worker, 10 concurrent | 3.62 req/s | 13.19 req/s | fibers — 3.6× |
Rule of thumb: if a route spends most of its time waiting on a slow external
service (http:: calls to third-party APIs, webhooks), turn fibers on — http::
is fiber-aware, so one thread can overlap many outbound calls. If your routes are CPU- or
DB-bound and fast, keep the default pool. Fibers are stable at c=200/500/1000 (zero errors),
they are simply not the faster choice for short requests.
Note: the fiber runtime is Linux-only (--mode http). Channels
(channel()/send/receive) are currently thread-blocking, so a
channel receive inside a fiber blocks that worker — keep channel use to parallel()
tasks for now.
Starting
# Windows XAMPP — 4 worker threads
Start-Process "C:\xampp\cgi-bin\look-fcgi.exe" -ArgumentList "--port 9000 --workers 4" -WindowStyle Hidden
# Linux systemd
ExecStart=/opt/look/lk-fcgi --port 9000 --workers 8
# if workers isn't specified → hardware_concurrency() (at least 2)
Architecture
look-fcgi --port 9000 --workers N
Each request → a worker thread from the ThreadPool:
1. shared_mutex (read lock for the duration of dispatch)
if a hot reload is needed → write lock → run_setup() → return to read lock
2. make_dispatch_copy() — built ONCE PER WORKER (thread_local), not per request
(rebuilt only when the app is hot-reloaded)
3. acquire_thread_connections() — one connection borrowed from each pool
4. dispatch_routes() / interpret() — fully isolated, thread-safe
5. release_thread_connections() — connections returned to the pool
6. HTTP response is written
Core Components
| Component | File | Description |
|---|---|---|
| ThreadPool | fcgi_main.cpp | N worker threads; each incoming TCP connection is assigned to a thread |
| ConnPool | web_stdlib.cpp | Per-DSN connection pool — SQLite: 1, MySQL/PG: N connections |
| make_dispatch_copy() | interpreter.cpp | Setup state is shared (read-only); output and call stack are per-request from scratch. The copy itself is thread_local — built once per worker, not per request (building it per request cost 30–63 µs, more than running the route; making it per-worker gave 2–3× web throughput). Invalidated on hot reload via HttpApp::generation. |
| shared_mutex | fcgi_main.cpp | Shared lock for concurrent dispatch; exclusive lock for hot reload |
Your LOOK Code Doesn't Change
You write it the same way — the runtime handles everything:
$conn = db::connect("mysql://root:@127.0.0.1/blog") # once at setup
route("GET", "/posts", function() use ($conn) {
# Each request runs on a different thread, borrowing a connection from $conn automatically
$rows = db::query($conn, "SELECT * FROM posts", [])
print(json::encode($rows))
})
SQLite + concurrent: SQLite pool_size=1 — writes are serialized, reads run concurrently via WAL mode. MySQL/PostgreSQL pool_size=workers.
init_core_modules: log::, file::, date::, error:: are now usable directly in the setup phase and in all route callbacks without use. log::warn(...) works during warm start, and error::new(...) can be thrown anywhere.
--mode http — Event Loop (Phase 13)
With look-fcgi --mode http, look-fcgi runs its own HTTP/1.1 server — bypassing Apache/FastCGI. Required for WebSocket.
Starting
# Windows XAMPP — independent of Apache
Start-Process "C:\xampp\cgi-bin\look-fcgi.exe" `
-ArgumentList "--mode http --port 8090 --workers 4 C:\xampp\htdocs\index.lk" `
-WindowStyle Hidden
# Linux systemd
ExecStart=/opt/look/look-fcgi --mode http --port 8090 --workers 4 /var/www/myapp/index.lk
The Two Modes Compared
| Feature | --mode fcgi | --mode http |
|---|---|---|
| Front server | Apache + mod_proxy_fcgi | None — direct TCP |
| WebSocket | ❌ | ✅ |
| SSE | ❌ | ✅ |
| timer:: | ❌ | ✅ |
| HTTP/1.1 | Apache parses it | look-fcgi parses it |
| Hot reload | ✅ mtime-based | ✅ mtime-based |
| Warm start | ✅ | ✅ |
| LOOK code | Unchanged | Unchanged |
Architecture
look-fcgi --mode http --port 8090
Request flow:
Client TCP connection
→ EventLoop (epoll Linux / select Windows) async read
→ HTTP/1.1 parse complete → WorkerPool thread
→ make_dispatch_copy() → dispatch_routes()
→ EventLoop async write → response → close fd
WebSocket Upgrade (Upgrade: websocket):
→ WorkerPool thread: 101 Switching Protocols is sent
→ WsConnection created, added to WsRegistry
→ route("WS", ...) callback is called
→ ws::on() callbacks registered, parallel() task starts
→ EventLoop async read → RFC 6455 frame decode → message callback
SSE Upgrade (Accept: text/event-stream):
→ WorkerPool thread: HTTP 200 + SSE headers are sent
→ SseConnection created, added to SseRegistry
→ route("SSE", ...) callback is called
→ timer::every() timers registered (TimerManager background thread)
→ sse::on("close") disconnect callback registered
→ EventLoop async read → disconnect detection → on_close_cb → timer::cancel
Reverse Proxy with Nginx (recommended)
# /etc/nginx/sites-available/myapp
server {
listen 443 ssl
server_name domain.com
location / {
proxy_pass http://127.0.0.1:8090
proxy_set_header X-Forwarded-For $remote_addr
}
location /chat {
proxy_pass http://127.0.0.1:8090
proxy_http_version 1.1
proxy_set_header Upgrade $http_upgrade
proxy_set_header Connection "upgrade"
proxy_read_timeout 3600s
}
}
Note: --mode fcgi is left entirely untouched — the same binary runs in both modes. You can open a separate port just for WebSocket without breaking your existing Apache+FastCGI setup.
HTTP Rate Limiter
Works only in --mode http. A two-layer token-bucket algorithm: global first (botnet protection), then per-IP (a single attacker). When the limit is exceeded it returns 429 Too Many Requests — your LOOK code doesn't change.
| Env Var | Default | Description |
|---|---|---|
LOOK_RATE_LIMIT_RPM | 0 (disabled) | Max requests per minute per IP. 0 = disabled. |
LOOK_RATE_LIMIT_BURST | = RPM | Instantaneous burst tolerance per IP (for SPA / mobile-app parallel requests). |
LOOK_RATE_LIMIT_GLOBAL_RPM | 0 (disabled) | Per-minute limit across all IPs — botnet protection. 0 = disabled. |
LOOK_RATE_LIMIT_GLOBAL_BURST | = GLOBAL_RPM | Global instantaneous burst tolerance. |
LOOK_TRUSTED_PROXY | — (disabled) | Comma-separated trusted proxy IPs or CIDRs. X-Forwarded-For from these IPs is used as the real client IP. |
# systemd service — behind an nginx proxy
[Service]
Environment=LOOK_RATE_LIMIT_RPM=120
Environment=LOOK_RATE_LIMIT_BURST=8
Environment=LOOK_RATE_LIMIT_GLOBAL_RPM=5000
Environment=LOOK_RATE_LIMIT_GLOBAL_BURST=200
Environment=LOOK_TRUSTED_PROXY=127.0.0.1
Check order: the global bucket is checked first. If it's full, the per-IP bucket isn't consulted — saving CPU. A global 429 returns Retry-After: 1, a per-IP 429 returns Retry-After: 60.
Careful without LOOK_TRUSTED_PROXY: if you're behind an nginx proxy and don't set this variable, all requests appear to come from nginx's IP (127.0.0.1) — the rate limiter counts every user as a single IP.
parallel() + channel() — Concurrent Tasks (Phase 14)
LOOK brings language-level concurrency. parallel() starts a new task and channel() passes the result back — LOOK-specific terminology, not Go's.
Basic API
# channel([size]) — create a channel
$ch = channel() # default buffer: 128
$ch = channel(10) # 10-slot buffer
$ch = channel(0) # unbuffered — synchronous rendezvous (Go semantics)
# send / receive / close / chan_size
send($ch, 42) # send a value to the channel (blocks if the buffer is full)
$val = receive($ch) # receive from the channel (blocks if empty; null if closed+empty)
close($ch) # close the channel — receive() starts returning null
chan_size($ch) # number of items in the queue
# parallel(fn) — start a new task
parallel(function() use ($ch, $conn) {
$rows = db::query($conn, "SELECT count(*) as n FROM products", [])
send($ch, $rows[0]["n"])
})
$count = receive($ch)
Buffer size & backpressure: channel(N) (N>0) buffers up to N items — send() blocks once the buffer is full, giving natural backpressure. channel(0) is unbuffered / synchronous (Go rendezvous): send() blocks until a receive() takes the value, so a producer can never outrun its consumer. Use channel(0) for hand-off synchronization and buffered channels for throughput.
Fan-out Pattern — Parallel Queries
# 3 DB queries concurrently — total time is that of the longest query
$result = channel(3)
parallel(function() use ($result, $conn) {
$n = db::query($conn, "SELECT count(*) as n FROM companies", [])
send($result, $n[0]["n"])
})
parallel(function() use ($result, $conn) {
$n = db::query($conn, "SELECT count(*) as n FROM categories", [])
send($result, $n[0]["n"])
})
parallel(function() use ($result, $conn) {
$n = db::query($conn, "SELECT count(*) as n FROM products", [])
send($result, $n[0]["n"])
})
$companies = receive($result)
$categories = receive($result)
$products = receive($result)
Pipeline Pattern — Producer → Consumer
$pipe = channel(100)
$out = channel()
# Producer
parallel(function() use ($pipe, $conn) {
$rows = db::query($conn, "SELECT id FROM products WHERE active=1", [])
foreach ($rows as $row) { send($pipe, $row["id"]) }
close($pipe)
})
# Consumer
parallel(function() use ($pipe, $out) {
$ids = []
while (true) {
$id = receive($pipe)
if ($id == null) { break }
push($ids, $id)
}
send($out, count($ids))
})
$total = receive($out)
Rules
| Rule | Description |
|---|---|
parallel() output | Ignored — get results only through a channel |
| HTTP context | Don't call request::, response::, session:: inside a task — those values don't belong to the current request |
| DB connection | Can be passed with use ($conn) — takes a separate slot from ConnPool |
receive() null | Means the channel is closed + empty — break out of the loop |
| Panic handling | Task panics are logged via log::error — the HTTP request is unaffected |
parallel:: — Task Monitoring API
Helper functions to monitor and control running tasks. No use parallel; needed.
| Function | Description | Returns |
|---|---|---|
parallel::active() | Number of tasks currently running | int |
parallel::wait($timeout_ms) | Wait until all tasks finish (ms). 0 = indefinitely. | bool (did all finish) |
parallel::limit() | The LOOK_PARALLEL_LIMIT value (0 = unlimited) | int |
parallel::at_capacity() | Is the limit reached? | bool |
# Task monitoring — a /status endpoint
route("GET", "/status", function() {
print(json::encode([
"active_tasks" => parallel::active(),
"limit" => parallel::limit(),
"at_capacity" => parallel::at_capacity()
]))
})
# Check capacity before enqueuing
if (parallel::at_capacity()) {
response::json(["error" => "Server busy"], 503)
return
}
parallel(function() { /* ... */ })
LOOK_PARALLEL_LIMIT — Task Limit
The maximum number of parallel() tasks that can run at once. Unlimited by default. When exceeded, the parallel() call throws an error.
# in the systemd service file:
Environment=LOOK_PARALLEL_LIMIT=100
# in the .env file:
LOOK_PARALLEL_LIMIT=50
Together with WebSocket: parallel() + channel() are the building blocks of WebSocket. For each WS connection, one task receives messages from the hub and forwards them to the client — see the WebSocket section.
WebSocket (Phase 15)
Full WebSocket support in LOOK. RFC 6455, SHA-1, Base64 — zero dependency. Works only in --mode http.
Prerequisite: WebSocket works only with look-fcgi --mode http. There is no WebSocket support in FastCGI mode (--mode fcgi).
Basic Chat Example
# Hub channel — top-level (created in the setup phase, passed to all WS routes)
$hub = channel()
route("WS", "/chat", function($ws) use ($hub) {
# A task that receives messages from the hub and forwards them to this client
parallel(function() use ($ws, $hub) {
while (true) {
$msg = receive($hub)
if ($msg == null) { break } # hub closed
ws::send($ws, $msg)
}
})
# On an incoming message, write to the hub and broadcast to all clients
ws::on($ws, "message", function($data) use ($hub) {
$parsed = json::decode($data)
$out = json::encode(["from" => request::ip(), "msg" => $parsed["msg"]])
send($hub, $out)
ws::broadcast($out)
})
ws::on($ws, "close", function() {
log::info("WS connection closed")
})
})
ws:: API
| Function | Description |
|---|---|
ws::send($ws, $msg) | Send a text frame to this WS connection |
ws::on($ws, "message", fn) | Message callback — fn($data) is called on every frame |
ws::on($ws, "close", fn) | On connection close — fn() |
ws::close($ws) | Send a close frame, close the connection |
ws::broadcast($msg) | Send to all connected WS clients (broadcast-safe) |
ws::clients() | Number of active WS connections |
Architecture
HTTP GET /chat
Upgrade: websocket
Sec-WebSocket-Key: ...
→ WorkerPool thread: ws_handshake_101() → 101 Switching Protocols
→ WsConnection{fd} is created
→ added to WsRegistry (the global list for broadcast)
→ route("WS", "/chat", fn) callback is called
→ ws::on() callbacks are registered
→ a parallel() task is started (reading from the hub)
→ frames start being read via EventLoop async_read(fd)
When a frame arrives (EventLoop thread):
→ ws_try_decode_frame() — RFC 6455, masked client frames
→ text/binary: WorkerPool thread → on_message callback
→ ping: pong is answered automatically
→ close: a close frame is sent, on_close is called, fd is closed
ws::broadcast():
→ WsRegistry shared_lock → the client list is copied → lock released
→ sent to each client individually (no deadlock)
Browser JavaScript Example
const ws = new WebSocket('ws://localhost:8090/chat')
ws.onopen = () => console.log('Connected')
ws.onmessage = e => console.log('Message:', JSON.parse(e.data))
ws.onclose = () => console.log('Closed')
ws.send(JSON.stringify({ msg: 'Hello!' }))
Starting the App (--mode http)
# Windows
Start-Process "C:\xampp\cgi-bin\look-fcgi.exe" `
-ArgumentList "--mode http --port 8090 --workers 4 C:\xampp\htdocs\index.lk" `
-WindowStyle Hidden
# Linux systemd
ExecStart=/opt/look/look-fcgi --mode http --port 8090 --workers 4 /var/www/myapp/index.lk
Hub pattern: $hub = channel() must be created at top-level (in the setup phase). A channel created inside a route callback is per-connection — it can't pass messages between clients.
timer:: + SSE — Server-Sent Events (Phase 16)
Timer management and Server-Sent Events in LOOK. timer:: runs on a global background thread; route("SSE",...) is the one-way sibling of WebSocket.
Prerequisite: SSE and timer:: work only with look-fcgi --mode http. They aren't supported in FastCGI mode (--mode fcgi).
timer:: API
| Function | Description |
|---|---|
timer::after(ms, fn) | Call fn once after ms → returns an int id |
timer::every(ms, fn) | Call fn repeatedly every ms → returns an int id |
timer::cancel($id) | Cancel the timer |
sse:: API
| Function | Description |
|---|---|
sse::send($sse, $data) | Send an SSE frame: data: ...\n\n → bool (false = disconnected) |
sse::send($sse, $data, "event") | Named event: event: ...\ndata: ...\n\n |
sse::on($sse, "close", fn) | fn() is called on connection close — use it to cancel timers |
sse::close($sse) | Close the connection |
sse::clients() | Number of active SSE connections |
Live Stats Example
# SSE route — to a client arriving with Accept: text/event-stream
route("SSE", "/events", function($sse) {
# Send a tick every 2 seconds
$tick = timer::every(2000, function() use ($sse) {
$data = json::encode([
"t" => date::now(),
"clients" => sse::clients()
])
$ok = sse::send($sse, $data, "tick")
if (!$ok) { timer::cancel($tick) } # disconnected
})
# A keepalive ping every 30 seconds
$ping = timer::every(30000, function() use ($sse) {
sse::send($sse, "keepalive", "ping")
})
# Cancel the timers when the connection closes
sse::on($sse, "close", function() use ($tick, $ping) {
timer::cancel($tick)
timer::cancel($ping)
log::info("SSE connection closed")
})
})
Browser JavaScript
const es = new EventSource('/events')
es.addEventListener('tick', e => {
const d = JSON.parse(e.data)
console.log(d.t, 'clients:', d.clients)
})
es.addEventListener('ping', e => console.log('ping', e.data))
es.onerror = () => console.log('Disconnected')
timer:: Standalone Usage
# One-shot — log after 5s
$id = timer::after(5000, function() {
log::info("5 seconds passed")
})
timer::cancel($id) # changed my mind
# Repeating — keep statistics
timer::every(60000, function() use ($conn) {
$n = db::col($conn, "SELECT count(*) FROM sessions WHERE active=1", [])
log::info("Active sessions: " . $n)
})
Architecture
SSE Upgrade:
Client GET /events
Accept: text/event-stream
→ http_server.cpp: req.upgrade_sse = true
→ handle_sse_upgrade(): HTTP 200 + SSE headers are sent
→ SseConnection{fd} is created, added to SseRegistry
→ sse_handler lambda → worker thread
→ make_dispatch_copy() → web.method = "SSE"
→ set_sse_connection(conn)
→ dispatch_routes() → route("SSE",...) callback
→ timer::every() is registered (TimerManager)
→ sse::on("close") is registered
→ EventLoop async_read(fd): disconnect detection
TimerManager (global singleton, background thread):
std::map<int, Entry> entries_ (sorted by next_fire time)
condition_variable::wait_until → sleep until the nearest deadline
Callback fires OUTSIDE THE LOCK (deadlock prevention)
one-shot: removed | repeating: next_fire updated
timer:: callback: make_dispatch_copy() + acquire/release_thread_connections()
Disconnect pattern: when sse::send() returns false, the connection has closed. Check it inside the timer callback and call timer::cancel($id). sse::on("close", fn) also works, but the send-check reacts faster.
Nginx — SSE Proxy (X-Accel-Buffering)
# proxy_buffering must be off for SSE
location /events {
proxy_pass http://127.0.0.1:8090
proxy_buffering off
proxy_cache off
proxy_set_header X-Forwarded-For $remote_addr
proxy_read_timeout 3600s
}
X-Accel-Buffering: no — automatically added to LOOK's SSE headers. When nginx sees this header, it disables buffering.
Bytecode VM — the default engine
A register-based bytecode VM is now the default engine for both the CLI (lk, lk -c) and the web server (lk-fcgi, in both --mode fcgi and --mode http). The compiler (AST → FunctionProto bytecode) + VM (switch(opcode) dispatch) pair replaced the tree-walk interpreter as the hot path: roughly 41–51× faster on the CLI and up to 7.8× on compute-bound web workloads. The tree-walk interpreter remains only as a fallback (and still powers the REPL, lk-cgi and lk test, where the VM isn't linked).
Safe fallback, on by default: the VM is chosen automatically. If a script uses something the compiler can't yet handle, execution falls back to the tree-walk interpreter before any output is committed — production never crashes. You can force the interpreter with LOOK_CLI_VM=0 (CLI) or LOOK_BYTECODE=0 (web). A healthy web deploy shows zero route-level fallbacks in the log.
Architecture
| Component | File | Description |
|---|---|---|
| Bytecode definitions | bytecode.h | Opcode enum, Instruction struct, FunctionProto, the Value::BYTECODE_FN type |
| Compiler | compiler.cpp / compiler.h | AST → FunctionProto; RegisterAllocator, alloc_seq(n) contiguous-block allocation |
| VM | vm.cpp / vm.h | Register-based, switch dispatch, CallFrame stack, TryCatchEntry, PARALLEL_CALL thread detach |
Important Bug Fixes (Phase 16.5)
| Bug | Impact | Fix |
|---|---|---|
| String-interpolation infinite loop | 100% CPU on the /product/{id} route pattern | Trigger condition narrowed to {$ — uses find("{$", i) |
| CALL_BUILTIN register contiguity | The second CALL_BUILTIN's args were in reverse order | alloc_seq(n) — bypass the free pool, contiguous block, locals_end_ update |
| MAKE_CLOSURE capture-hint ordering | The closure was created without captures, "Capture index out of range" error | MOVE/LOAD_GLOBAL preloads are emitted BEFORE MAKE_CLOSURE |
| Assoc-array foreach double-free | It entered the LOCAL register pool twice, overwriting the value | if (next_ > locals_end_) locals_end_ = next_ in alloc_seq |
| db::connect VM setup | "Not callable" error | Added BUILTIN_NAMES[56]="db::connect" |
| channel() VM support | LOAD_GLOBAL "channel" → None → crash | Added BUILTIN_NAMES[57]="channel" |
Benchmark — Docker Ubuntu, 8 workers
| Endpoint | Description | Interpreter | VM | Difference |
|---|---|---|---|---|
| /heavy | 10,000-iteration arithmetic | 182 RPS | 1,427 RPS | 7.8x ↑ |
| /light | 100-iteration string concat | 8,829 RPS | 15,653 RPS | 1.77x ↑ |
| /router | No DB, pure routing | 8,715 RPS | 8,521 RPS | ~1x (HTTP I/O bound) |
| /string | 500-iteration large concat | 3,467 RPS | 2,697 RPS | 0.78x (LOAD_GLOBAL overhead) |
Conclusion: the VM gains 7.8x on compute-bound workloads. On HTTP+I/O-bound workloads the difference is dominated by HTTP overhead.
Usage
Port note: --mode http runs independently of FastCGI. Port 9000 is reserved for FastCGI; use a separate port for --mode http (e.g. 8090, 9001).
# --mode http — VM active (default), a separate port from FastCGI
./look-fcgi --mode http --port 8090 --workers 8 /var/www/myapp/index.lk
# VM disabled — interpreter fallback
LOOK_BYTECODE=0 ./look-fcgi --mode http --port 8090 --workers 8 /var/www/myapp/index.lk
# the chat service (Phase 17)
LOOK_BYTECODE=1 ./look-fcgi --mode http --port 9001 --workers 8 chat.lk
# --mode fcgi — FastCGI (via Apache mod_proxy_fcgi) — VM also default here
./look-fcgi --port 9000 --workers 8
Your LOOK code doesn't change: when the VM kicks in, nothing in your .lk files needs changing. Existing scripts run as-is.
Phase 17 — Chat Live
A real-time chat application using WebSocket + broadcast + timer:: keepalive. 500/500 bots PASS, zero memory leak verified.
Live: the look-chat service, --mode http --port 9001 --workers 8
chat.lk Routes
| Route | Type | Description |
|---|---|---|
| /chat | WS | WebSocket chat — broadcast, hub channel, 30s keepalive ping |
| /status | GET | JSON health — connected client count, uptime, memory |
| /monitor | GET | HTML stress-test dashboard |
| /monitor/events | SSE | Real-time stream of test results |
| /monitor/results | GET | All test results as JSON |
| /monitor/run/{id} | POST | Start a stress test (parallel() in the background) |
Core Architecture
# Hub channel — created in the setup phase
$hub = channel()
route("WS", "/chat", function($ws) use ($hub) {
# Keepalive ping — every 30 seconds
$ping_id = timer::every(30000, function() use ($ws) {
ws::send($ws, json::encode(["type" => "ping"]))
})
# Read from the hub → forward to this client
parallel(function() use ($ws, $hub) {
while (true) {
$msg = receive($hub)
if ($msg == null) { break }
ws::send($ws, $msg)
}
})
ws::on($ws, "message", function($data) use ($hub) {
send($hub, $data) # write to the hub
ws::broadcast($data) # broadcast to all clients
})
ws::on($ws, "close", function() use ($ping_id) {
timer::cancel($ping_id)
})
})
Test Results — Phase 17 + 17.1 (June 16, 2026)
| Test | Goal | Result | Date |
|---|---|---|---|
| vm-stress | VM vs interpreter compute speedup | ✅ 7.8x /heavy compute (ms=8000) | Jun 16 |
| concurrent-500 | 500/500 WS bots at once | ✅ 500/500 PASS | Jun 16 |
| crash-recovery | Automatic recovery after restart | ✅ PASS | Jun 16 |
| parallel-ws | WS send from inside parallel() | ✅ PASS | Jun 16 |
| vm-fallback | LOOK_BYTECODE=0 fallback works | ✅ PASS | Jun 16 |
| hot-reload | Service reloads when chat.lk changes | ✅ PASS | Jun 16 |
| unicode-xss | Unicode and XSS payloads pass through safely | ✅ PASS | Jun 16 |
| session-vm | Sessions work correctly in VM mode | ✅ PASS | Jun 16 |
| concurrent-1000 | 5000 req, c=1000, 0 fail | ✅ 11,008 RPS, 0 fail (ab, localhost) | Jun 16 |
| ws-1000 | 1000 concurrent WebSocket connections | ✅ 1000 connections established, broadcast works | Jun 16 |
| memory-1h (Phase 17) | No short-term memory leak | ✅ glibc high-water mark; +0.5MB/200 connections, real leak zero | Jun 16 |
| memory-72h | 72-hour RSS monitoring, 100MB delta threshold | 🔄 Ongoing — start=97MB, ends Jun 19, 2026 | Jun 16–19 |
concurrent-1000: ab -n 5000 -c 1000 http://localhost:9001/router — 11,008 RPS, max 508ms, 0 failed. VM mode (LOOK_BYTECODE=1), 8 workers, Docker Ubuntu.
ws-1000: websocat v1.13.0 musl — 1000 parallel bash subprocesses, each connected to WS /chat. All connections established, ws::clients() peak=1000, broadcast active.
timer::cancel Fix (Phase 17)
Old behavior: when timer::cancel(id) was called, the timer thread waited for the sleep to finish — for a 30-second keepalive, it waited 30 seconds.
Fix: entries_.erase(id) immediately + the thread is woken with cv_.notify_one(). The cancel call is now instant.
Memory Behavior
| State | RSS | Comment |
|---|---|---|
| Start | ~8 MB | LOOK runtime |
| 200 connections | ~8.5 MB | +0.5 MB |
| 4000 connections (cumulative) | ~200 MB | glibc high-water mark — not returned to the OS |
| Real leak | Zero | valgrind / heaptrack verified |
LOOK Concurrency Model
LOOK offers concurrency at two levels: at the runtime level (each HTTP request on a separate thread) and at the language level (parallel() + channel()).
Two Layers
| Layer | Model | Developer Effort |
|---|---|---|
| Runtime (look-fcgi) | N worker threads — each HTTP request in an isolated interpreter copy | Zero — the runtime handles it |
| Language (parallel + channel) | LOOK tasks — parallel work within a single request | Minimal — channel design is enough |
When to Use What?
| Scenario | Solution |
|---|---|
| Different users' requests should run in parallel | Automatic in the runtime — --workers N is enough, no code changes |
| 3 DB queries in a single request should run in parallel | parallel() + channel() — fan-out pattern |
| Real-time data stream (chat, notifications) | route("WS",...) + parallel() + channel() |
| One-way server-to-client data stream (stats, feed) | route("SSE",...) + timer::every() |
| Periodic background task (clear cache, log stats) | timer::every(ms, fn) — registered in the setup phase |
| Do a long operation in the background, respond immediately | parallel() + get the result via a channel |
LOOK Concurrency Model
| Layer | How it works |
|---|---|
| Runtime | Thread pool (look-fcgi) — --workers N, per-request isolated state |
| Language level | parallel() + channel() — optional, only when needed |
| Web code | Concurrency isn't required — synchronous code runs at full speed too |
Thread Safety
| Problem | Solution |
|---|---|
| Two threads modify the same interpreter state | make_dispatch_copy() — each request in its own copy |
| Two threads use the same DB connection | ConnPool — each thread borrows a separate connection |
| A request arrives during hot reload | shared_mutex — reload exclusive, dispatch shared |
| WS broadcast deadlock | The client list is copied, the lock released, then sent |
| parallel() task panic | Logged via log::error — the HTTP request is unaffected |
Simple rule: for ordinary HTTP routes you don't need parallel() — the runtime already runs each request on a separate thread. parallel() is only for doing multiple things at once within a single request.
Apache / XAMPP Configuration
Windows XAMPP — Automatic Install
One step: run platforms/windows/xampp/install.bat as administrator.
# what install.bat does:
# 1. copies lk-cgi.exe → C:\xampp\cgi-bin\
# 2. adds a CGI handler to httpd.conf (with mod_rewrite rules)
# 3. restarts Apache
# 4. verifies with http://localhost/test.lk
Once complete, open http://localhost/test.lk — you should see Hello LOOK!.
httpd.conf — CGI Mode (Manual)
If install.bat doesn't work, add the block below inside <Directory "C:/xampp/htdocs">:
# --- LOOK Language CGI ---
Action look-handler /cgi-bin/lk-cgi.exe
AddHandler look-handler .lk
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^ /index.lk [L]
</IfModule>
# --- /LOOK Language ---
Then restart Apache.
Note: the rewrite rules must be inside the <Directory> block. At global scope, %{REQUEST_FILENAME} -f always returns false.
How CGI Mode Works
| Request | Behavior |
|---|---|
/test.lk, /api.lk | lk-cgi.exe runs the file, the output goes back to the browser |
/, /about | The rewrite rule routes to index.lk (the router) |
*.html, *.css, *.js, images | Apache serves them directly, they don't reach LOOK |
Ubuntu / Linux Apache
# mpm_prefork + CGI required
sudo a2dismod mpm_event
sudo a2enmod mpm_prefork cgi rewrite actions
sudo systemctl restart apache2
Detailed guide: docs/ubuntu-deployment.md | docs/plesk-apache-deployment.md
Performance
Apache Bench — 5000 requests, 500 concurrent connections:
| Endpoint | CGI | FastCGI Warm | Difference |
|---|---|---|---|
| GET / (DB query) | 82 RPS | 418 RPS | 5x |
| GET /admin/me (no DB) | 81 RPS | 2150 RPS | 26x |
| GET /menu/... (heavy query) | 70 RPS | 234 RPS | 3.3x |
The core advantage of FastCGI warm start: the DB connection is established once and stays alive for the life of the process. The cost of opening a new connection on every request is zero.
Real Load Test — Plesk VPS (v1.0.0, June 15, 2026)
Apache Bench — 10 minutes, 32 workers, 17 endpoints, 4.7 million requests:
| Mode | Workers | RPS | RSS | Duration / Requests | Errors |
|---|---|---|---|---|---|
| fcgi (Apache proxy) | 8 | ~8,000 | 648 KB | — | crashed after 90s |
| fcgi (Apache proxy) | 32 | 7,846 | 8 MB | 10 min / 4.7M | 0 |
| http interpreter (Apache bypass) | 32 | 2,490 | — | 5,000 requests | 0 |
| http interpreter (Apache bypass, DB) | 32 | 182 | — | 5,000 requests | 0 |
Note: the RPS ceiling of fcgi mode comes from the Apache mod_proxy_fcgi layer — the bottleneck is Apache, not look-fcgi. --mode http bypasses Apache entirely.
Bytecode VM Benchmark — Docker Ubuntu (v1.0.0, June 16, 2026)
8 workers, --mode http, VM active:
| Endpoint | Description | Interpreter | VM | Difference |
|---|---|---|---|---|
| /heavy | 10,000-iteration arithmetic loop | 182 RPS | 1,427 RPS | 7.8x ↑ |
| /light | 100-iteration string concat | 8,829 RPS | 15,653 RPS | 1.77x ↑ |
| /router | Pure routing (no DB) | 8,715 RPS | 8,521 RPS | ~1x |
| /string | 500-iteration large string concat | 3,467 RPS | 2,697 RPS | 0.78x |
On compute-bound workloads the VM gains 7.8x. On HTTP/I/O-bound workloads the difference is dominated by HTTP overhead.
Load Ladder — --mode http, direct port (July 2026)
AlmaLinux 8 VPS — --mode http, 64 workers, Bytecode VM active, kernel tuning
(backlog 16384, syn_backlog 8192), ApacheBench. The same SELECT 1+1 route at all concurrency levels:
| Concurrency | Total requests | RPS | Errors | RSS |
|---|---|---|---|---|
| c=1,000 | 50,000 | 7,902 | 0 | 29 MB |
| c=5,000 | 100,000 | 7,566 | 0 | 29 MB |
| c=10,000 | 200,000 | 7,593 | 0 | 28 MB |
| c=20,000 | 200,000 | 7,432 | 0 | 28 MB |
| 1M endurance | 1,000,000 | 7,731 | 0 | 28 MB |
| Blocking, c=200 | 20,000 | 10,558 | 0 | 28 MB |
Findings:
- Throughput is flat from c=1,000 to c=20,000 (~7.5–10k) — this proves the system is CPU-bound, with no scheduler/lock bottleneck.
- 1,000,000 requests, 0 errors — RSS stayed at 28 MB from start to finish, not a single byte grew (no memory leak).
- ~1.75M requests total in this run, 0 errors. The walls were the OS, not LOOK:
tcp_max_syn_backlogand thelisten()backlog — overcome with persistent tuning. - TLS chain (nginx→direct FCGI): ~1,100–1,240 RPS — the difference is double TLS termination + the proxy layer, not LOOK.
- DB-bound: the bottleneck is the MySQL round-trip — the VM has no effect, linear up to connection-pool saturation.
- Note: real c=100,000+ concurrent from a single machine is physically impossible (ephemeral ports ~64k). 1M total requests was tested as endurance.
Security & Concurrency Tests (July 2026)
The zero-dependency philosophy makes every parser (HTTP, MySQL, RESP2, PostgreSQL) our responsibility. So beyond benchmarks, the runtime has been put through fuzzing, ThreadSanitizer and data-integrity tests:
| Test | Scope | Result |
|---|---|---|
| AddressSanitizer + UBSan fuzzing | 16,000+ iterations of random/malformed HTTP (including chunked) | 0 crashes · 0 UB · 0 memory errors |
| ThreadSanitizer | Concurrent-dispatch data-race scan (blocking) | 0 races (the date::now tz race found was fixed) |
| Cross-contamination (data leakage) | 20,000 requests, unique token + DB round-trip, blocking + fiber | 0 data leakage |
| Memory leak | RSS monitoring over 1.75M requests | 28 MB steady — no leak |
| Regression suite | Automated tests for 9 past production bugs | 9/9 passing |
| Manual security review | 4 rounds of line-by-line review: protocol parsers, VM arithmetic, network path | 17 hardening points closed |
| IMAP server fuzzing (ASan+UBSan) | Malformed-input battery: overflowing/negative literals, broken seq-set, SEARCH tokenizer, protocol violation, path traversal | 0 UB · 0 crashes |
| Mail chain end-to-end (interop) | SMTP delivery → per-user Maildir → IMAP LOGIN+SELECT+FETCH reads the same message | Passed — SMTP↔IMAP aligned |
Vulnerabilities closed:
- DoS — unbounded body: exceeding
LOOK_MAX_BODY_SIZE(10 MB) → 413; memory exhaustion prevented. - Request smuggling: Content-Length + Transfer-Encoding together → 400 (RFC 7230 §3.3.3).
- Malformed Content-Length: invalid value → 400 (worker doesn't crash); full RFC 7230 §4.1 chunked Transfer-Encoding support.
- SQL injection:
db::queryis parameterized (?placeholder) — driver-correct escaping, locale-independent. - Session: 128-bit
/dev/urandomtoken,HttpOnly + Secure + SameSitecookie. - Slowloris: read/write timeout (30s) + kernel backlog tuning.
- Data race (date::now):
std::localtimeshared-buffer + tzset race →localtime_r+ startuptzset(). Caught by ThreadSanitizer, fixed. - JSON stack overflow: deeply nested body → recursion bounded with
JSON_MAX_DEPTH(256) — network input viarequest::json()can't crash the worker. - Protocol-parser DoS: RESP2/HTTP-client/PostgreSQL/SMTP — unguarded
std::sto*number conversion + unbounded allocation (RESP2read_bulk/read_line) closed. - Arithmetic undefined behavior (UB): signed-integer overflow (+/−/*),
INT_MIN/-1, shift amount and-INT_MIN→ promoted to float on overflow; zero runtime errors under UBSan. - IMAP server (RFC 3501/2177): APPEND literal size is validated before reading (
NO [TOOBIG], OOM prevention) ·LOGINDISABLEDbefore TLS (passwords don't go in the clear) · path traversal rejected in 3 layers (mailbox+user+SMTP recipient) · sequence stability (the wrong message isn't deleted) · Slowloris/brute-force protection · ASan+UBSan fuzz 0 UB.
Layered defense: manual review (17 points) + fuzzing (16,000+ iterations) + ThreadSanitizer + regression (9/9) + CI. No absolute "zero bugs" is claimed — the main attack surface (network I/O, protocol parsers, VM arithmetic) has been scanned and hardened.
Fuzzing, ASan/UBSan and TSan run automatically in CI on every push (.github/workflows/security.yml).
Details: SECURITY.md in the repo root.
Phase 18: Blog Live
A live blog — template engine, CRUD, auth, file upload. June 18, 2026.
Features
- template:: — layout inheritance, partial include, HTML-safe variables
- Auth — session-based admin login, PBKDF2/SHA-256 password hash
- Post CRUD — create, edit, delete, publish state
- Category management — slug-based, post counts
- File upload — magic-byte-checked cover image
- Flash messages — success/error notifications via the session
Route Structure
GET /blog/ → Post list (published, category filter)
GET /blog/category/{slug} → Posts by category
GET /blog/{slug} → Post detail
GET /blog/admin/login → Login form
POST /blog/admin/login → Auth check + session
GET /blog/admin/logout → Session destroy
GET /blog/admin/ → Management panel
GET /blog/admin/new → New post form
POST /blog/admin/new → Save post + image upload
GET /blog/admin/edit/{id} → Edit form
POST /blog/admin/edit/{id} → Update post
POST /blog/admin/delete/{id} → Delete post
GET /blog/admin/categories → Category list
POST /blog/admin/categories/new → Add category
POST /blog/admin/categories/delete/{id} → Delete category
Phase 18.5: File Module System
Splitting large projects into multiple files with use "file.lk". June 18, 2026. 5/5 tests PASS.
Blog.lk Refactor Example
Split from a single 421-line file into 5 files — a 43-line bootstrap:
blog/
blog.lk ← 43-line bootstrap
helpers.lk ← db_check(), admin_check(), slugify()
routes/
public.lk ← register_public_routes($conn, $tpl)
auth.lk ← register_auth_routes($conn, $tpl)
admin.lk ← register_admin_routes($conn, $tpl)
Test Results
| Test | Result |
|---|---|
| Was the function imported? | ✅ PASS |
| Did the $hidden variable stay contained? | ✅ PASS — Undefined variable |
| Was the const imported? | ✅ PASS |
| Does a circular include error? | ✅ PASS — Circular include error |
| use inside a function → LookParseError? | ✅ PASS |
Anatomy of a LOOK File
Login and data fetching — one file, no framework. See how LOOK is designed for the web in a single example.
Login + Data Fetching
use auth;
$conn = db::connect("mysql://root:@localhost/blog");
# Opened once — stays alive across every request (warm start)
route("POST", "/login", function() use ($conn) {
session::start();
$d = request::json();
# ? parameterized — SQL injection protection is automatic
$rows = db::query($conn,
"SELECT * FROM users WHERE email=?",
[$d["email"]]);
if (count($rows) == 0 ||
!auth::verify($d["password"], $rows[0]["hash"])) {
response::status(401);
print(json::encode(["ok" => false]));
return;
}
session::set("user_id", $rows[0]["id"]);
response::redirect("/panel");
});
route("GET", "/posts", function() use ($conn) {
$rows = db::query($conn,
"SELECT * FROM posts", []);
print(json::encode($rows));
});
What It Provides
| Topic | How LOOK does it |
|---|---|
| DB connection | Warm start — opened once, stays alive for the life of the process |
| HTTP variables | request:: session:: namespaces — no hidden globals |
| Routing | route("GET", "/path/{id}", fn) built into the language |
| SQL injection | db::query($conn, sql, [params]) — ? auto-parameterized |
| Password hash | auth::hash / auth::verify (PBKDF2-SHA256) in core |
| WebSocket / SSE | route("WS",...) — no separate setup |
| Concurrency | parallel() + channel() inside the language |
| Performance | Bytecode VM — 10,000+ RPS on a direct port, single core |
Easy deployment: register the binary with Apache/nginx and run. FastCGI, standalone HTTP or CGI — one binary, three modes.
Feature Table
LOOK v1.0.0 — all completed features.
Available in v1.0.0
| Feature | Note | |
|---|---|---|
| Basic types (int, float, string, bool, null) | ✅ | |
| Numeric array & associative array | ✅ | Separate concepts — no mixing |
| String interpolation | ✅ | Full expressions supported: {$price * 1.18} |
| Backtick raw string | ✅ | Multi-line, no escaping |
| Null coalescing ?? | ✅ | Chaining supported |
Ternary ?: | ✅ | Nested chaining supported |
| switch | ✅ | Go-style — no break, multiple case, switch(true) pattern |
| All operators | ✅ | + - * / % ** . <=> && || & | ^ ~ << >> |
| Compound assignment, ++ -- | ✅ | |
| if / elseif / else | ✅ | |
| while / for / foreach | ✅ | including break / continue |
| Functions (named, anonymous, variadic, recursive) | ✅ | |
| Closure explicit capture | ✅ | use ($conn) required |
| try / catch / finally | ✅ | |
Module system use X; use X as Y; | ✅ | |
File module system use "file.lk" | ✅ | Function/const export, $var isolation, cycle protection |
| template:: engine | ✅ | Layout inheritance, partial, {#if}, {#each}, {#block} |
| route() — routing built into the language | ✅ | URL parameters, 404 handler |
| request:: / response:: / json:: | ✅ | HTTP primitives |
| session:: / cookie:: | ✅ | |
| db:: — MySQL | ✅ | Zero dependency, parameterized queries |
| db:: — SQLite | ✅ | sqlite://./data.db or sqlite://:memory: |
| db:: — PostgreSQL | ✅ | Wire protocol v3, MD5 auth, zero dependency |
| Automatic type conversion | ✅ | INT → int, DECIMAL → float — no manual conversion needed |
| FastCGI warm start | ✅ | Persistent DB, hot reload |
| Linux / Ubuntu 24.04 support | ✅ | GCC build — CLI, CGI, FastCGI |
| AlmaLinux 8 / Plesk support | ✅ | Docker build + Apache test — FastCGI + CGI fully working |
| Plesk VPS deployment | ✅ | AlmaLinux 8.10, Plesk Obsidian 18.0.76, nginx→Apache→lk-fcgi — live (see test.codlook.com) |
| Route-less script (direct mode) | ✅ | Scripts without route() (like hello.lk) run in look-fcgi via a fresh interpret |
| env() / config() | ✅ | .env file, dot-notation |
| log:: — daily rotation | ✅ | |
| auth:: — PBKDF2-SHA256 | ✅ | |
| validator:: / html:: / type:: | ✅ | |
| math:: / string:: / array:: | ✅ | including array::find/any/all, array::push/pop, string::pad_left/pad_right |
| Stack overflow protection (depth 500) | ✅ | |
| GC — reference counting | ✅ | |
| struct — Go-style data grouping | ✅ | Default values, nested structs, arrays, $obj.field read/write, clean json::encode output |
| const iota — constant groups | ✅ | Go-style chain, expression iota, top-level const global scope |
Concurrent runtime — --workers N | ✅ | ThreadPool + per-DSN ConnPool + make_dispatch_copy() — your LOOK code doesn't change |
| init_core_modules — log:: file:: date:: without use | ✅ | Accessible in the warm-start setup phase without use |
--mode http — Event Loop | ✅ | epoll (Linux) / IOCP (Windows) — Apache bypass, its own HTTP/1.1 server, unlimited connections |
parallel() — task | ✅ | LOOK task; make_dispatch_copy() + std::thread::detach; panic → log::error |
channel() — channel | ✅ | send / receive / close / chan_size; buffered (size N) or unbuffered/synchronous (size 0) |
WebSocket — route("WS",...) | ✅ | RFC 6455 frame codec, SHA-1+Base64 zero dependency, WsRegistry broadcast-safe |
| ws:: module | ✅ | ws::send / on / close / broadcast / clients — --mode http only |
SSE — route("SSE",...) | ✅ | Server-Sent Events; SseConnection + SseRegistry; Accept: text/event-stream detection |
| sse:: module | ✅ | sse::send / on / close / clients — named events, disconnect detection, --mode http only |
| timer:: module | ✅ | timer::after / every / cancel — global TimerManager, background thread, lock-free callback |
--mode http bug fix | ✅ | EPOLLOUT edge-triggered bug fixed (level-triggered); SIGPIPE ignore added — the connection was dropping after the first request |
| Bytecode VM | ✅ | Register-based VM; compiler.cpp (AST→FunctionProto); vm.cpp (switch dispatch); 30/30 tests PASS; 7.8x compute speedup; LOOK_BYTECODE=0 fallback |
| timer::cancel fix | ✅ | Cancellation is now instant — erase(id) + cv_.notify_one(); the old behavior waited 30s |
| WebSocket Chat live | ✅ | 500/500 bots PASS, 0 memory leak, look-chat service on port 9001 |
| VM CALL goto fix | ✅ | The run() outer while loop wasn't restarting after push_back → named function bodies never ran; fixed with goto call_dispatch |
| PrintStatement reg0 clobber fix | ✅ | emit(CALL_BUILTIN,0,print,r) was overwriting reg0; param $name became null; fixed with emit(CALL_BUILTIN,r,print,r) |
| PARALLEL_CALL SEGV fix | ✅ | SharedState.builtins raw ptr → local req_builtins dangling pointer; isolated with a deep-copy builtins_copy in the goroutine lambda |
| concurrent-1000 | ✅ | ab -n 5000 -c 1000 localhost:9001 — 11,008 RPS, 0 failed requests, max 508ms |
| ws-1000 | ✅ | websocat v1.13.0 musl — 1000 concurrent WebSocket connections, all established, broadcast active |
| Security review — Session | ✅ | Secure+SameSite=Lax flags; /dev/urandom cryptographic RNG; destroy()+start() session-fixation protection; cookies_in.erase fix |
| Security review — XSS / Path Traversal | ✅ | process_content() → html::escape; use "file.lk" → weakly_canonical + main_script_ root confinement |
| Test infrastructure — 22/24 PASS | ✅ | Stability / Performance / Security / Platform — see LOOK_TEST_RESULTS.md |
| VS Code Extension v0.1.0 | ✅ | look-lang-0.1.0.vsix — syntax highlighting, 33 snippets, bracket matching; .lk file support |
VS Code Extension v2.0 + lk --check (v1.0) | ✅ | Full IntelliSense (26 modules/153 methods + globals, hover, signature), completion for your own symbols, outline; live error checking — lk --check (parse-only, without running) catches syntax + undefined function calls; Run/Serve/REPL commands + shortcuts; auto-detects lk |
look test — test runner | ✅ | look test [pattern] [--verbose]; 10 built-in asserts; before_each/after_each; isolated dispatch copy; 29/29 PASS |
look repl — interactive shell | ✅ | linenoise embed; auto-print => val; multi-line blocks; stdlib auto-loaded; :help/:vars/:clear/:exit |
cache:: module | ✅ | set/get/has/delete/flush/size/keys; TTL; CacheStore singleton — shared by all workers; 12/12 tests PASS |
queue:: module | ✅ | Named FIFO queues; push/pop/peek/size/clear/names; persistent cross-request; 9/9 tests PASS |
jobs:: module | ✅ | SQLite durable job queue; pending→processing→done/failed; retry; delayed; worker handler; run/recover; 23/23 tests PASS |
look install — package manager | ✅ | Download a GitHub zipball, extract to pkg/, update look.lock; zero dependency (miniz amalgamation) |
mail:: module (v1.0.0) | ✅ | Mailgun / SendGrid / Postmark; mail::send / send_html / provider; env MAIL_PROVIDER+MAIL_API_KEY+MAIL_FROM |
jobs::recover() — crash recovery (v1.0.0) | ✅ | processing → pending, log::warn; recovers stuck jobs when the app restarts; 3/3 tests PASS |
| Windows IOCP — unlimited connections (v1.0.1) | ✅ | IocpEventLoop — AcceptEx + WSARecv + WSASend; replacing SelectEventLoop (max 64); c=500 0 errors |
| DKIM outbound signing (v1.x) | ✅ | dkim.cpp — signs outgoing e-mail with RSA-SHA256; integrated into the mail:: module |
| File sandbox security fix (v1.x) | ✅ | assert_in_file_root() dangling-iterator fixed — std::mismatch iterators were used outside the root_path scope; path traversal (../) blocked |
| SMTP relay protection (v1.x) | ✅ | Port 25 outbound SMTP blocked — only authenticated SMTP (587/465) allowed |
| Embedded IMAP server — IMAP4rev1 (v1.0.0) | ✅ | RFC 3501 + RFC 2177 IDLE. SELECT/FETCH/STORE/EXPUNGE/APPEND/SEARCH/IDLE; STARTTLS + IMAPS(993). Same Maildir + mail_users pbkdf2 as SMTP. Security: literal OOM cap, LOGINDISABLED, 3-layer path traversal, sequence stability. ASan+UBSan fuzz 0 UB; end-to-end SMTP↔IMAP interop passed |
Short lambda — fn + arrow => (v1.0.0) | ✅ | fn = alias of function (lexer); expression-bodied arrow fn($x) => $x*2 auto-returns a single expression (parser). VM unchanged; use capture + higher-order work. 6/6 tests |
app:: service registry (v1.0.0) | ✅ | An escape hatch from use ($conn) boilerplate. app::set/get/has/db — registers a shared service at setup, routes access without capture. ServiceRegistry (shared_ptr+mutex) shared by dispatch copies; concurrency/capture semantics unchanged. VM setup+dispatch wired; regression 7/7 |
response::error + dot access (v1.0.0) | ✅ | response::error(404,"msg") → status + {"ok":false,"error":msg} in one line. $row.column dot access (parser converts to ["column"]). Canonical style: don't use ; |
| HTTP rate limiter (v1.x) | ✅ | LOOK_RATE_LIMIT_RPM env var — per-IP request rate limit; real-IP detection behind a proxy with LOOK_TRUSTED_PROXY |
error:: core module (v1.x) | ✅ | Auto-loaded without use error; — always accessible like log::, file::, date:: |
task_* rename (v1.x) | ✅ | Runtime-wide goroutine_* → task_*; env var: LOOK_PARALLEL_LIMIT (old name LOOK_GOROUTINE_LIMIT — no longer valid); LOOK-specific terminology |
| Plesk extension — workspace (v1.0) | ✅ | Sidebar panel (Dashboard/Applications/Logs/Documentation), in-browser code editor (Save & Redeploy), live per-domain monitor, journalctl log viewer, English UI; one-command install from a GitHub release; scripts via sudo /bin/bash (exec-bit independent) |
| AlmaLinux 8 binary — GCC 12 (v1.x) | ✅ | gcc-toolset-12 (GCC 12.2.1), OpenSSL 1.1.1k; GLIBC 2.28 compatible; verified on Plesk Obsidian 18.0.76 |
| Plesk VPS live (v1.x) | ✅ | AlmaLinux 8.10, Plesk Obsidian 18.0.76, nginx→Apache→lk-fcgi port 9100; automatic install via the Plesk extension |
Intentionally Absent
| Feature | Why | |
|---|---|---|
| OOP (class / interface / trait) | — | Coming as a use orm; extension in Phase 13 |
| enum keyword | — | const + iota is enough — Go made the same decision |
| Mixed arrays | — | Data-structure clarity — numeric and associative kept separate |
| Global HTTP variables | — | Explicit access via request:: is preferred |
| Type declarations | — | Will remain a dynamically typed scripting language |
| match expression | — | switch is enough — one decision construct, no need for two |
Design Limits
The decisions below were discussed during the language's development and deliberately left out of scope.
| Topic | Decision |
|---|---|
| db::pool() API | FastCGI warm start already holds the connection for the life of the process — no separate pool API needed |
| route::run() | The end of the script dispatches automatically. No need to call it; calling it errors |
| Global HTTP variables | Always access via request:: — where it comes from must be clear |
| Mixed arrays | Numeric and associative arrays stay separate — clarity over "take everything" |
| OOP in the language core | Doesn't enter the language core. Planned later as a use orm; extension |
| Template engine in core | Coming as a use template; extension |
| Class-based router | route() is enough — object-based routing adds a layer, which LOOK doesn't want |
Package System — look install
The LOOK package manager is GitHub-based: it downloads directly from github.com/user/repo, extracts to a pkg/ folder, and locks with look.lock. No registry, no config, no dependency tree — the MVP is simple and it works.
📦 Package & module directory: discover Codlook-approved and community-contributed packages at packages.codlook.com — search, filter, copy the install command. Modules (lk module install, global) and packages (lk install, project-specific) are listed here: jwt, http, crypto, mail, cache, queue…
Installation
# Install a package from GitHub (main branch)
look install github.com/ali/look-stripe
# A specific branch or tag
look install github.com/ali/look-stripe@v1.2
look install github.com/ali/look-stripe@develop
# Install all packages in look.lock (CI / a new machine)
look install
Output
Installing: ali/look-stripe (main)
87 KB downloaded
→ pkg/ali/look-stripe
look.lock updated
✓ ali/look-stripe installed
Usage:
use "pkg/ali/look-stripe/stripe.lk"
Folder Structure
project/
index.lk
look.lock ← committed to Git
pkg/ ← added to .gitignore
ali/
look-stripe/
stripe.lk
helpers.lk
look.lock Format
Generated automatically. ref@sha per package — the same commit is installed on reinstall.
# look.lock — generated by look install, do not edit manually
"ali/look-stripe" = "main@a3f8c21d..."
"zeynep/look-mail" = "v2.1@b7e4d09f..."
Usage — index.lk
# load a file under pkg/ directly with use "..."
use "pkg/ali/look-stripe/stripe.lk"
route("POST", "/payment", function() {
$amount = float(request::post("amount"))
$result = stripe_charge($amount, request::post("card"))
print(json::encode($result))
})
.gitignore
# pkg/ downloaded packages — reinstalled with look install
pkg/
jobs.db
Technical Architecture
| Step | Detail |
|---|---|
| Download | GitHub API zipball endpoint — api.github.com/repos/{user}/{repo}/zipball/{ref} |
| HTTPS | The existing http_client (Schannel / OpenSSL) — zero extra dependency |
| Redirect | The GitHub 302 → codeload.github.com chain is followed automatically |
| ZIP extraction | miniz amalgamation (322 KB, included) — the GitHub prefix (repo-sha/) is stripped automatically |
| SHA resolution | api.github.com/repos/{user}/{repo}/commits/{ref} — full 40-character commit hash |
| look.lock | Sorted, deterministic "user/repo" = "ref@sha" format |
Official Codlook Repositories
The package and module repositories managed by Codlook are on GitHub:
| Repository | Contents | Install |
|---|---|---|
| Codlook/look-packages | Official packages — jwt, payment integrations, etc. | look install github.com/Codlook/look-packages |
| Codlook/look-modules | Community modules — third-party integrations | look install github.com/Codlook/look-modules |
Out of Scope (v1.1+)
| Feature | Version |
|---|---|
| packages.codlook.com registry | v1.1 |
Semver resolution (^1.2, ~1.0) | v1.1 |
look publish | v1.1 |
| Dependency tree (transitive deps) | v1.1 |
| Native C++ extension API | v1.1 |
Test Runner
The look test command is embedded in look.exe — like go test in Go. No separate binary. Each test runs in an isolated VM context.
Commands
look test # all .lk files in the tests/ folder
look test auth # tests/test_auth.lk (pattern matching)
look test --verbose # detailed output
Writing Tests
use assert
# Test registration — runs when the file is executed
test("create user", function() {
$user = create_user("ali", "ali@example.com")
assert_not_null($user)
assert_eq($user["name"], "ali")
assert_match($user["email"], "@")
})
test("invalid email is rejected", function() {
assert_throws(function() {
create_user("ali", "invalid")
})
})
DB Test — before_each / after_each
$conn = db::connect(env("DB_DSN", "sqlite://./test.db"))
before_each(function() use ($conn) {
db::exec($conn, "BEGIN", []) # start a transaction
})
after_each(function() use ($conn) {
db::exec($conn, "ROLLBACK", []) # roll back after each test
})
test("insert record", function() use ($conn) {
db::exec($conn, "INSERT INTO t VALUES(?)", ["test"])
$rows = db::query($conn, "SELECT * FROM t", [])
assert_eq(count($rows), 1)
# after_each → ROLLBACK → clean DB
})
Assert Functions
| Function | Description |
|---|---|
assert($val) | The value must be truthy |
assert_true($val) | The value must be true/truthy |
assert_false($val) | The value must be false/falsy |
assert_eq($a, $b) | The two values must be equal |
assert_neq($a, $b) | The two values must differ |
assert_null($val) | The value must be null |
assert_not_null($val) | The value must not be null |
assert_contains($arr, $v) | The array must contain the element |
assert_throws(fn) | The function must throw an error |
assert_match($str, $regex) | The string must match the regex |
Module syntax is also supported: use assert; assert::eq($a, $b);
Output Format
LOOK Test Runner v1.0
test_auth.lk
✅ create user (2ms)
✅ invalid email is rejected (1ms)
❌ password hash verification
assert_eq() failed:
expected: true
actual: false
2 failed — 28/30 passed — 45ms
Architecture
| Feature | Detail |
|---|---|
| Location | Embedded in look.exe — no separate binary |
| Isolation | Each test gets a separate VM context via make_dispatch_copy() |
| before_each | Runs before each test (db::begin pattern) |
| after_each | Runs after each test, even on error (db::rollback pattern) |
| File discovery | The tests/ folder, recursive .lk scan |
| Pattern | look test auth → tests/test_auth.lk or files with "auth" in the name |
| Exit code | 0 = all passed, 1 = failures (CI/CD compatible) |
REPL
look repl — an interactive environment. Embedded in look.exe, no separate binary. linenoise-based: arrow-key history, line editing, cross-platform (Windows Console API + POSIX termios).
Starting
look repl
LOOK v1.0.0 REPL
Type :exit or Ctrl+C to quit
>>>
Auto Print
>>> 5 + 5
=> 10
>>> string::upper("look")
=> "LOOK"
>>> math::sqrt(144.0)
=> 12
>>> true && false
=> false
You don't need to write print() — the expression result is shown as => value.
Variables and Functions
>>> $x = 42
=> 42
>>> $x * 2
=> 84
>>> $fn = function($n) { return $n * $n }
=> <function>
>>> $fn(9)
=> 81
Multi-line Blocks
>>> $fn = function($x) {
... return $x * 2
... }
=> <function>
>>> $fn(21)
=> 42
When a { opens it switches to the ... prompt, and executes when the block closes with }.
Standard Modules Auto-loaded
# no need to write use — everything is loaded at REPL startup
>>> string::split("a,b,c", ",")
=> ["a", "b", "c"]
>>> math::pow(2, 10)
=> 1024
>>> date::today()
=> "2026-06-20"
Commands
| Command | Description |
|---|---|
:help | Command list |
:vars | List defined variables |
:clear | Clear the screen |
:exit | Quit (or Ctrl+C / Ctrl+D) |
Architecture
| Feature | Detail |
|---|---|
| Line editing | linenoise embed — zero external dependency |
| History | 200 lines, arrow-key navigation, saved to a .look_history file |
| Persistent state | Variables and functions live for the whole session |
| AST ownership | owned_programs — closures hold pointers to the AST, alive until the REPL exits |
| Multi-line | The count of open { is tracked, ... prompt until it closes |
| Error → continue | A runtime error can't crash the REPL; the error is shown and it continues |
cache::
An in-memory, thread-safe, TTL-supporting cache. Global under FastCGI warm start — all worker threads share the same cache. Loaded with use cache;.
API
| Function | Description |
|---|---|
cache::set($key, $val [, $ttl]) | Write a value. TTL in seconds (0 = persistent) |
cache::get($key) | Read a value → null (if absent or expired) |
cache::has($key) | Existence check → bool |
cache::delete($key) | Delete → bool |
cache::flush() | Clear everything |
cache::size() | Number of valid entries (expired ones don't count) |
cache::keys() | All keys → string[] (sorted) |
Basic Usage
use cache
# Persistent
cache::set("config", ["debug" => false, "version" => "1.0"])
# 5-minute TTL
cache::set("user:42", $user, 300)
$val = cache::get("user:42") # returns null once the TTL passes
if (cache::has("session_count")) {
print("Active: " . cache::get("session_count"))
}
remember pattern — cache a DB result
# cache::remember = a LOOK idiom (Go: compose it, no special syntax)
function cache_remember($key, $ttl, $fn) {
$val = cache::get($key)
if ($val == null) {
$val = $fn()
cache::set($key, $val, $ttl)
}
return $val
}
# Usage
route("GET", "/menu", function() use ($conn) {
$products = cache_remember("menu_all", 300, function() use ($conn) {
return db::query($conn, "SELECT * FROM products WHERE active=1", [])
})
print(json::encode($products))
})
FastCGI Warm Start — Global Cache
# index.lk — in the setup phase (once)
$conn = db::connect(env("DB_DSN"))
# The cache singleton starts empty — it fills on the first request
route("GET", "/api/stats", function() use ($conn) {
$stats = cache::get("stats")
if ($stats == null) {
$stats = db::query($conn, "SELECT count(*) as n FROM products", [])[0]
cache::set("stats", $stats, 60) # cache for 60 seconds
}
print(json::encode($stats))
})
Architecture
| Feature | Detail |
|---|---|
| Storage | std::unordered_map<string, CacheEntry> — global singleton |
| Thread safety | std::mutex — all operations atomic |
| TTL | std::chrono::steady_clock — lazy eviction (checked on get/has/size) |
| Warm start | Lives for the FastCGI process — NOT affected by hot reload |
| Sharing | All worker threads share one store (make_dispatch_copy doesn't copy it) |
queue::
An in-memory, thread-safe, named FIFO queue. Global under FastCGI warm start — lives across all requests. No persistence (reset when the process exits). For a durable queue use jobs::. Loaded with use queue;.
API
| Function | Description |
|---|---|
queue::push($name, $val) | Add a value to the queue (at the end) |
queue::pop($name) | Take a value from the queue (from the front) → null (if empty) |
queue::peek($name) | See the front value without removing it → null |
queue::size($name) | Number of elements → int |
queue::clear($name) | Empty the queue |
queue::names() | All existing queue names → string[] |
Basic Usage
use queue
# Add to the queue
queue::push("notification", ["type" => "comment", "id" => 42])
queue::push("notification", ["type" => "like", "id" => 43])
# Take from the queue
$item = queue::pop("notification") # ["type"=>"comment","id"=>42]
$item = queue::pop("notification") # ["type"=>"like","id"=>43]
$item = queue::pop("notification") # null (empty)
# Size
print(queue::size("notification")) # 0
Cross-request Pattern — Passing Data Between Requests
use queue
# POST route — enqueue, respond immediately
route("POST", "/event", function() {
$data = request::json()
queue::push("events", $data)
print(json::encode(["ok" => true]))
})
# GET route — collect the accumulated events in bulk
route("GET", "/events/flush", function() {
$items = []
while (true) {
$item = queue::pop("events")
if ($item == null) { break }
push($items, $item)
}
print(json::encode(["count" => count($items), "items" => $items]))
})
queue:: vs jobs:: — Comparison
| Feature | queue:: | jobs:: |
|---|---|---|
| Storage | RAM (std::deque) | SQLite (disk) |
| Persistence | ❌ Reset when the process exits | ✅ Survives restart |
| Speed | ✅ Microseconds | Milliseconds (SQLite I/O) |
| Retry | ❌ | ✅ Automatic (max_retries) |
| Delayed jobs | ❌ | ✅ delay_sec parameter |
| Use | Transient data, cross-request buffer | Email, SMS, background jobs |
jobs::
A SQLite-based, durable, thread-safe job queue. Jobs aren't lost even if the app restarts. Delayed jobs, a retry mechanism, a dead-letter queue and a worker loop are included. Loaded with use jobs;.
Status Lifecycle
pending → processing → done
↘ failed (retry_count ≥ max_retries)
fail() → pending (retry_count < max_retries — automatic retry)
retry() → pending (manual, only from failed)
API
| Function | Description |
|---|---|
jobs::push($queue, $payload [, $max_retries=3 [, $delay_sec=0]]) | Add a job → returns an id. If delay_sec > 0 it's a delayed job — ready N seconds from now |
jobs::next($queue) | Take the next ready pending job → assoc | null. Delayed jobs are invisible until run_after passes |
jobs::done($id) | Mark the job done → processing → done |
jobs::fail($id) | Failure — goes to pending if retry_count < max_retries, otherwise to failed |
jobs::stats($queue) | → {pending, processing, done, failed} counts |
jobs::list($queue, $status [, $limit=100]) | List jobs → array |
jobs::failed($queue [, $limit=100]) | Dead-letter queue — shortcut for jobs::list($q, "failed") |
jobs::retry($id) | failed → pending (manual retry) |
jobs::purge($queue, $status) | Delete jobs in the given status → number deleted |
jobs::worker($queue, $fn) | Register a handler for the queue. $fn($job) → true (done) / false (fail) |
jobs::run([$interval_ms=5000]) | Worker loop. interval_ms > 0 → infinite loop (a separate process). interval_ms = 0 → one pass, returns (embed with a timer) |
Usage Pattern 1 — Embedded Worker (single process, no terminal)
The simplest deployment: both the router and the worker run inside index.lk. jobs::run(0) runs one pass and returns, and timer::every triggers it periodically.
use jobs
# Signup route — enqueue the email, don't hold the request
route("POST", "/signup", function() use ($conn) {
db::exec($conn, "INSERT INTO members ...", [...])
jobs::push("email", json::encode([
"type" => "welcome",
"to" => request::post("email"),
"name" => request::post("name")
]))
print(json::encode(["ok" => true]))
})
# Register the handler (in the setup phase, at the same level as routes)
jobs::worker("email", function($job) {
$p = json::decode($job["payload"])
try {
# send email...
return true # done
} catch ($e) {
log::error("Email error: {$e}")
return false # fail (counts as a retry)
}
})
# One pass every 5 seconds — non-blocking, a single process is enough
timer::every(5000, function() {
jobs::run(0)
})
Usage Pattern 2 — Separate Worker Process (production recommendation)
The worker runs in a separate worker.lk file. It auto-starts via systemd and is restarted on crash.
# worker.lk — a separate process: look worker.lk
use jobs
jobs::worker("email", function($job) {
$p = json::decode($job["payload"])
# send email...
return true
})
jobs::worker("sms", function($job) {
$p = json::decode($job["payload"])
# send sms...
return true
})
# Infinite loop every 5s — runs until the process exits
jobs::run(5000)
Delayed Jobs
The fourth parameter delay_seconds — the job becomes ready N seconds from now. jobs::next() doesn't see jobs that aren't ready.
# Enqueue now, process in 1 hour
jobs::push("email", json::encode($payload), 3, 3600)
# A subscription reminder in 24 hours
jobs::push("reminder", json::encode(["user_id" => $id]), 3, 86400)
# Immediately (no delay)
jobs::push("email", json::encode($payload)) # max_retries=3, delay=0
Dead Letter Queue
Jobs that exhaust all attempts fall into the failed status. Monitor with jobs::failed(), restore manually with jobs::retry().
# Monitor failed jobs
route("GET", "/admin/jobs", function() {
print(json::encode([
"email_stats" => jobs::stats("email"),
"email_failed" => jobs::failed("email", 20) # the last 20 failures
]))
})
# Manually retry a specific job
route("POST", "/admin/jobs/{id}/retry", function($id) {
jobs::retry(int($id))
print(json::encode(["ok" => true]))
})
# Clean up old done jobs
route("POST", "/admin/jobs/purge", function() {
$n = jobs::purge("email", "done")
print(json::encode(["deleted" => $n]))
})
Job Assoc Structure
jobs::next() and jobs::list() return the following fields:
| Field | Type | Description |
|---|---|---|
id | int | Unique job ID |
queue | string | Queue name |
payload | string | Job data (usually via json::encode()) |
retry_count | int | How many times it has been attempted so far |
max_retries | int | Maximum number of attempts |
run_after | int | Unix timestamp — not processed before this time |
created_at | int | Creation time (Unix timestamp) |
updated_at | int | Last update time |
Architecture
| Feature | Detail |
|---|---|
| Storage | SQLite (jobs.db) — customizable via the JOBS_DB env |
| Persistence | Jobs aren't lost even if the app restarts |
| Thread safety | std::mutex + SQLite autocommit — safe for concurrent workers |
| Retry | failed once max_retries is exceeded — manual retry with jobs::retry() |
| Delayed jobs | run_after column — next() only takes jobs whose time has come |
| jobs::run(0) | One-pass mode — non-blocking, embedded use with timer::every |
| jobs::run(N) | Infinite loop — a separate process / look worker.lk + systemd |
| Dependencies | Zero — SQLite amalgamation, already included |
| queue:: vs jobs:: | queue:: RAM, fast, transient / jobs:: SQLite, durable, retry + delay included |
jobs::recover() — Crash Recovery
Returns jobs stuck in the processing status back to pending when the app has crashed. Should be called at startup.
use jobs
# Startup — crash recovery (at the top of index.lk, before route() definitions)
$recovered = jobs::recover("email") # all processing → pending
$recovered = jobs::recover("email", 300) # only ones stuck 5min+
# Results are written to the log
# [WARN] [jobs::recover] Crash recovery: 2 processing job(s) → pending
| Function | Description |
|---|---|
jobs::recover($queue) | All processing → pending; returns how many jobs were recovered (int) |
jobs::recover($queue, $min_age) | Recover only ones stuck longer than $min_age seconds (0 = all) |
jobs::failed($queue) | List failed jobs (a jobs::list alias) |
Full Usage Example — Email Queue
use jobs
# index.lk startup — crash recovery
jobs::recover("email")
# Signup route — push a job
route("POST", "/signup", function() use ($conn) {
$data = request::json()
db::exec($conn, "INSERT INTO members(email) VALUES(?)", [$data["email"]])
jobs::push("email", ["to" => $data["email"], "tpl" => "welcome"], 3)
print(json::encode(["ok" => true]))
})
# worker.lk — a separate process (runs as a systemd service)
use jobs
jobs::worker("email", function($job) use ($conn) {
use mail
$p = $job["payload"]
$r = mail::send($p["to"], "Welcome!", "Your account is ready.")
return $r["ok"] # if it returns false, jobs retries (max 3)
})
jobs::run(5000) # run every 5 seconds (blocking)
# Or inside the same process with timer:: (timer::every + jobs::run(0))
timer::every(10000, function() {
jobs::run(0) # one pass — no infinite loop
})
Embedded Mail Server — SMTP + IMAP
LOOK includes a zero-dependency, embedded SMTP (MTA, RFC 5321) and IMAP4rev1 (RFC 3501 + RFC 2177 IDLE) server. No Postfix, Dovecot or Courier required. They come up with the same command, in the same process — you can write a secure mail system end to end with LOOK.
The chain rests on a single idea: SMTP receives → writes to Maildir → IMAP serves.
# SMTP :25/:587 IMAP :143/:993
# │ ▲
# ▼ │
# <LOOK_MAIL_DIR>/<user>/inbox/{new,cur,tmp} ← shared Maildir
#
# Incoming letter: RCPT TO:<alice@myapp.com> → /var/mail/look/alice@myapp.com/inbox/new/
# Client reads: LOGIN alice@myapp.com → sees the same INBOX (SEARCH, FETCH, IDLE)
Both share the same mail_users table and the same PBKDF2 identity
(mail_user_auth) — a single user record is valid for both outgoing (SMTP submission) and
incoming (IMAP). The crypto is in one place, no code duplication.
Three Servers in One Command
# .env
LOOK_SMTP_PORT=25 # MTA (server→server delivery)
LOOK_SMTP_SUB_PORT=587 # Submission (authenticated outgoing)
LOOK_SMTP_LOCAL_DOMAINS=myapp.com
LOOK_IMAP_PORT=143 # IMAP — upgraded via STARTTLS
LOOK_IMAP_PORT_TLS=993 # IMAPS — implicit TLS (optional)
LOOK_IMAP_CERT=/etc/look/mail.crt # if absent, LOOK_SMTP_CERT is shared
LOOK_IMAP_KEY=/etc/look/mail.key
LOOK_MAIL_USER_DB=mysql://user:pass@127.0.0.1/db # mail_users (SHARED with SMTP)
LOOK_MAIL_DIR=/var/mail/look
lk --mode http --port 7400 --workers 4
# All at once: HTTP :7400 · SMTP :25/:587 · IMAP :143 · IMAPS :993
IMAP — Supported Commands
| Command | Function |
|---|---|
CAPABILITY · NOOP · LOGOUT | IMAP4rev1 IDLE (+ STARTTLS LOGINDISABLED before TLS) |
STARTTLS | Upgrades a plaintext connection to TLS (RFC 3501 §6.2.1) |
LOGIN | mail_users PBKDF2 verification (or dev single-user) |
SELECT · EXAMINE · STATUS · LIST · LSUB | Select/examine mailbox; Maildir++ subfolders |
FETCH | seq-set · FLAGS UID RFC822.SIZE BODY[] BODY[HEADER] BODY[TEXT] |
STORE · EXPUNGE | Change flags (read/deleted), purge \Deleted |
APPEND | Upload a message from the client to the mailbox (literal + flags) |
SEARCH | flag · FROM/TO/CC/SUBJECT · BODY/TEXT · HEADER · seq-set — AND-ed |
IDLE | Live new-mail push (RFC 2177) — no polling needed |
Security
- Credential privacy: plaintext
LOGINis rejected while TLS is available (LOGINDISABLED/NO [PRIVACYREQUIRED]) — the password doesn't go in the clear. - OOM protection: the
APPENDliteral size is validated before it's read (LOOK_IMAP_MAX_LITERAL, 32 MB) →NO [TOOBIG]. - Path traversal: mailbox + user + SMTP recipient name —
../absolute/control-character rejection in three layers. - Sequence stability: seq numbers are stable within a session (RFC 3501) — the wrong message isn't deleted.
- DoS: Slowloris (
SO_RCVTIMEO) · brute-force delay · connection/line/error limits. - Verification: ASan+UBSan fuzz (malformed literal/seq-set/tokenizer/traversal) → 0 UB, 0 crashes; end-to-end SMTP↔IMAP interop test passed.
Detailed reference: docs/imap-server.md (IMAP) and docs/smtp-server.md (SMTP + DKIM/SPF). To create a mail user, standard db::exec + auth::hash is enough — there's no separate management module.
mail:: — Sending Email
A zero-dependency email module. One API, multiple providers — to switch provider just edit .env.
It uses the existing http:: infrastructure (Schannel / OpenSSL).
use mail
# A simple text email
$r = mail::send("user@example.com", "Welcome!", "Your account is ready.")
# HTML email (text + HTML)
$r = mail::send("user@example.com", "Order", "Your order was received.", "<h1>Thank you!</h1>")
# HTML shortcut
$r = mail::send_html("user@example.com", "Invoice", "<h1>Total: $150</h1>")
# From override
$r = mail::send("user@example.com", "Subject", "Text", "", "noreply@myapp.com")
# Result check
if (!$r["ok"]) {
log::error("Mail failed: " . $r["message"])
}
# Active provider
print(mail::provider()) # → "mailgun"
mail:: API
| Function | Description | Returns |
|---|---|---|
mail::send($to, $subject, $text [,$html [,$from]]) | Send a text or text+HTML email | {ok, status, message} |
mail::send_html($to, $subject, $html [,$from]) | HTML-only email — empty text body | {ok, status, message} |
mail::provider() | Active provider name (from env) | string |
mail::deliver_maildir($base_dir, $mailbox, $from, $data) | Deliver a raw RFC 5322 message in Maildir format — for the SMTP handler and job processors | bool |
Provider Configuration (.env)
You select the provider from .env alone, without changing code:
# .env — Mailgun (default)
MAIL_PROVIDER=mailgun
MAIL_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxx
MAIL_FROM=noreply@myapp.com
MAIL_DOMAIN=mg.myapp.com # optional — derived automatically from MAIL_FROM
# .env — SendGrid
MAIL_PROVIDER=sendgrid
MAIL_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx
MAIL_FROM=noreply@myapp.com
# .env — Postmark
MAIL_PROVIDER=postmark
MAIL_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
MAIL_FROM=noreply@myapp.com
| Provider | MAIL_PROVIDER value | Auth method | Success code |
|---|---|---|---|
| Mailgun | mailgun | HTTP Basic (api:key) | 200 |
| SendGrid | sendgrid | Bearer token | 202 |
| Postmark | postmark | X-Postmark-Server-Token header | 200 |
Async Email with jobs:: (Recommended Pattern)
use jobs
use mail
# Route: respond instantly — the mail is enqueued
route("POST", "/signup", function() use ($conn) {
$data = request::json()
# ... save the user ...
jobs::push("email", ["to" => $data["email"], "tpl" => "welcome"], 3)
print(json::encode(["ok" => true]))
})
# worker.lk — a separate process (systemd service)
use jobs
use mail
jobs::worker("email", function($job) {
$p = $job["payload"]
$r = mail::send($p["to"], "Welcome!", "Your account is ready.")
return $r["ok"] # false → automatic retry (max 3)
})
jobs::run(5000)
jobs:: retry mechanism automatically retries transient API errors.
DKIM — Outbound Signing
Since v1.x, mail:: signs outgoing e-mail with DKIM (RSA-SHA256). The receiving server verifies the signature via DNS — the spam score drops and deliverability improves.
# systemd service or .env — DKIM configuration
LOOK_SMTP_DKIM_DOMAIN=myapp.com
LOOK_SMTP_DKIM_SELECTOR=mail # DNS record: mail._domainkey.myapp.com
LOOK_SMTP_DKIM_KEY_FILE=/etc/dkim/myapp.com.private
| Env Var | Description |
|---|---|
LOOK_SMTP_DKIM_DOMAIN | The signed domain (must be the same domain as MAIL_FROM) |
LOOK_SMTP_DKIM_SELECTOR | DNS selector — <selector>._domainkey.<domain> TXT record |
LOOK_SMTP_DKIM_KEY_FILE | RSA private key file path (PEM format) |
If all three env vars are set, DKIM is automatically active — the mail::send() call doesn't change.
SMTP Server Limit Settings
DoS-protection parameters for the embedded SMTP server (--mode smtp). All values have sensible defaults; no need to change them unless required.
| Env Var | Default | Description |
|---|---|---|
LOOK_SMTP_MAX_CONN | 1000 | Maximum concurrent connections |
LOOK_SMTP_MAX_MSG_SIZE | 25 MB | Maximum message size (bytes) |
LOOK_SMTP_MAX_RCPT | 100 | Maximum recipients per message |
LOOK_SMTP_MAX_ERRORS | 5 | Maximum errors per connection — dropped when exceeded |
LOOK_SMTP_MAX_CONNS_IP | 10 | Maximum concurrent connections per IP |
LOOK_SMTP_BANNER | localhost | Hostname shown in the SMTP greeting banner |
IMAP Server — Serve Incoming Mail (IMAP4rev1)
The embedded SMTP delivers the letter to Maildir; to let users read it, enable the embedded
IMAP server (LOOK_IMAP_PORT). The two share the same mail_users
table and the same Maildir — SMTP receives, IMAP serves. Thunderbird, Roundcube, Apple Mail
and mobile clients can connect and list, read, write, search (SEARCH) mail and
receive instant notifications (IDLE push). RFC 3501 + RFC 2177. No Dovecot/Courier required.
| Env Var | Default | Description |
|---|---|---|
LOOK_IMAP_PORT | — | IMAP port (143) — upgraded via STARTTLS. IMAP is off if empty. |
LOOK_IMAP_PORT_TLS | 0 | IMAPS / implicit TLS port (993) — opened only if a certificate exists |
LOOK_IMAP_CERT / _KEY | — | TLS certificate/key (PEM). If absent, LOOK_SMTP_CERT/KEY is shared. |
LOOK_MAIL_USER_DB | — | mysql://… — LOGIN from the mail_users table (falls back to LOOK_SMTP_USER_DB) |
LOOK_MAIL_USER / _PASS | — | Single-user fallback if no DB (dev) — constant-time comparison |
LOOK_MAIL_DIR | /var/mail/look | Maildir root — INBOX: <dir>/<user>/inbox |
LOOK_IMAP_MAX_LITERAL | 32 MB | APPEND literal cap — NO [TOOBIG] without reading if exceeded (OOM prevention) |
LOOK_IMAP_MAX_CONN / _MAX_LINE / _MAX_ERRORS | 1000 / 8KB / 5 | Connection, line, error limits (DoS) |
LOOK_IMAP_IDLE_TIMEOUT | 1800 | Idle-connection recv timeout — seconds (Slowloris + RFC autologout) |
The same command brings up three servers at once: lk --mode http --port 7400 → HTTP :7400 · SMTP :25/:587 · IMAP :143 · IMAPS :993. Details: docs/imap-server.md in the repo root.
SMTP User Management
LOOK's embedded SMTP server (LOOK_SMTP_USER_DB) authenticates users from a database table.
There's no separate module for user management — standard db:: operations are enough.
# 1. Create the table (once — in a migration or an admin route)
db::exec($conn,
"CREATE TABLE IF NOT EXISTS mail_users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
active TINYINT DEFAULT 1
)", [])
# 2. Add a user — hash the password with auth::hash()
db::exec($conn,
"INSERT INTO mail_users (email, password) VALUES (?, ?)",
[$email, auth::hash($password)])
# 3. Delete a user
db::exec($conn,
"DELETE FROM mail_users WHERE email=?",
[$email])
# 4. List users
$users = db::query($conn,
"SELECT id, email, active FROM mail_users ORDER BY email", [])
# 5. Active/inactive toggle
db::exec($conn,
"UPDATE mail_users SET active=? WHERE email=?",
[$active, $email])
Admin panel example — protected with route-level middleware:
$auth = function() {
# Header or token verification
if (request::header("X-Admin-Token") != env("ADMIN_TOKEN")) {
response::status(401)
response::json(["error" => "Unauthorized"])
stop()
}
}
route("POST", "/admin/mail-users", [$auth], function() use ($conn) {
$data = request::json()
db::exec($conn,
"INSERT INTO mail_users (email, password) VALUES (?, ?)",
[$data["email"], auth::hash($data["password"])])
response::json(["ok" => true])
})
route("DELETE", "/admin/mail-users/{email}", [$auth], function($email) use ($conn) {
db::exec($conn, "DELETE FROM mail_users WHERE email=?", [$email])
response::json(["ok" => true])
})
# .env — database connection for SMTP auth
LOOK_SMTP_USER_DB=mysql://user:pass@127.0.0.1/mydb
# Single-token auth (dev/test — not used if a DB is present)
# LOOK_SMTP_AUTH_TOKEN=change_me_smtp_token
The mail_users.password column must store a hash produced by auth::hash() — a plaintext password must never be written. The LOOK SMTP server verifies automatically.
SMTP Relay Protection
Since v1.x, the LOOK runtime blocks outbound SMTP connections over port 25. This prevents LOOK applications from being abused as an open relay.
| Port | Status | Description |
|---|---|---|
| 25 | ❌ Blocked | Outbound SMTP relay — no connection can be made from within LOOK |
| 587 | ✅ Allowed | STARTTLS + authentication — mail:: provider APIs use this port |
| 465 | ✅ Allowed | SMTPS (SSL) — authentication required |
Plesk Extension
A full LOOK hosting workspace from the Plesk panel — an in-browser code editor, a live per-domain monitor, a log viewer and a system dashboard. Install with one command, add a domain, write your code, go live.
Requirements
- Plesk Obsidian 18.0+
- systemd Linux — AlmaLinux/RHEL/Rocky/CloudLinux · Ubuntu/Debian Plesk
- Root access (for the initial install — Plesk Terminal or SSH)
Step 1 — Install
Upload look-lang-plesk-1.0.0.zip via Plesk Panel → Extensions → Upload Extension, or from a root terminal:
plesk bin extension --uninstall look-lang 2>/dev/null
wget -O /tmp/look-lang-plesk-1.0.0.zip "https://github.com/codlook/look/releases/download/v1.0/look-lang-plesk-1.0.0.zip"
plesk bin extension --install /tmp/look-lang-plesk-1.0.0.zip
Expected output: The extension was successfully installed.
Step 2 — Open the Panel
Plesk Panel → Extensions → LOOK Language, or https://<server>:8443/modules/look-lang/. Left sidebar: Dashboard · Applications · Logs · Documentation.
Step 3 — Add a Domain & Write Code
- Applications → Add New Domain — pick a domain (script path and a free port fill in automatically), choose the mode, Add & Start.
- It shows Running in the list. Use Edit Code to edit
index.lkin the browser → Save & Redeploy.
Workspace Features
| Feature | Description |
|---|---|
| Dashboard | Live CPU / Memory / Disk / Uptime — with mini charts (sampled every 4s) |
| Edit Code | In-browser index.lk editor → Save & Redeploy (write file + restart service) |
| Monitor | Live per-domain: status, CPU%, memory (RSS), PID, active connections, port, restarts, uptime + log tail |
| View Logs | journalctl -u look-<domain> — colored + copy to clipboard |
| Configure / Restart / Stop / Remove | Actions menu — systemd service management |
Architecture & Modes
Each domain is a systemd service (look-<domain>): nginx :443 → Apache :7081 (ProxyPass) → lk-fcgi :<port>.
| Mode | Description | Use |
|---|---|---|
fcgi | FastCGI — Apache mod_proxy_fcgi | Production recommendation, standard REST/web |
http | HTTP — lk-fcgi --mode http | Direct HTTP port, reverse proxy |
Notes
| Topic | Description |
|---|---|
| One-time sudo grant | Plesk doesn't run the post-install hook and strips the exec bit when extracting the zip. Scripts are invoked via sudo /bin/bash (no chmod needed); on a new server /etc/sudoers.d/look-lang is created once as root — preserved across install/uninstall cycles. |
| State | The domain list is at /usr/local/psa/var/modules/look-lang/domains.json (panel-writable) |
| Updates | Replaced in place when a new ZIP is uploaded; domain configuration is preserved |
Details: docs/plesk-extension.md · docs/plesk-extension-install.md
Windows + XAMPP Installation
One command. lk-cgi.exe is copied to cgi-bin, httpd.conf is patched, a sample test.lk is created.
Easiest way — the release package: download look-lang-xampp-1.0.0.zip from Releases (binaries embedded), extract it, and run it in Administrator PowerShell:
Expand-Archive look-lang-xampp-1.0.0.zip look; cd look; .\install.ps1
Custom XAMPP path: .\install.ps1 -XamppDir "D:\xampp" · Uninstall: .\uninstall.ps1. Then restart Apache and open http://localhost/test.lk.
The steps below are for developers who build from source and install from the repo.
Requirements
- Windows 10/11 (64-bit)
- XAMPP 8.x — installed at
C:\xampp\ - Visual C++ 2022 Redistributable
- The LOOK source code built:
cpp\build\Release\lk-cgi.exe
Step 1 — Build
cd cpp\build
cmake --build . --config Release
# Output: lk.exe, lk-cgi.exe, lk-fcgi.exe
Step 2 — Install
Right-click platforms\windows\xampp\install.bat → Run as administrator
# install.bat does the following in order:
# 1. copies lk-cgi.exe → C:\xampp\cgi-bin\
# 2. adds a CGI handler + mod_rewrite block to httpd.conf
# 3. stops and restarts Apache
# 4. auto-verifies with http://localhost/test.lk
Step 3 — Test
http://localhost/test.lk # should return Hello LOOK!
http://localhost/ # the index.lk router should work
Uninstall
To remove LOOK from XAMPP completely, open PowerShell as administrator:
# 1. delete lk-cgi.exe
Remove-Item "C:\xampp\cgi-bin\lk-cgi.exe" -Force
# 2. manually delete the LOOK block in httpd.conf (between the # --- LOOK ... # --- /LOOK lines)
# 3. restart Apache
Troubleshooting
| Error | Cause | Solution |
|---|---|---|
| 500 Internal Server Error | lk-cgi.exe didn't run | Check the last lines of C:\xampp\apache\logs\error.log |
| Source code shows in the browser | The CGI handler wasn't loaded | Run install.bat as administrator again |
| 404 Not Found | The httpd.conf Directory block is missing | Run install.ps1 directly |
| Windows Defender warning | Unsigned binary | Allow → run again |
lk-cgi.exe not found error | Not built | Do Step 1 |
Roadmap
| Phase | Contents | Status |
|---|---|---|
| 0–5 | Foundation, Core Language, Stdlib, CGI, Web, DB | ✅ Done |
| 6 | QR Menu — 29 routes, full CRUD, session auth | ✅ Done |
| 7 | Stress test — zero language errors | ✅ Done |
| 8 | FastCGI warm start — 5x performance, persistent DB | ✅ Done |
| 8.1 | Ternary ?:, array::find/any/all | ✅ Done |
| 8.2 | MySQL automatic type conversion | ✅ Done |
| 8.5 | SQLite — DbConnection interface, zero dependency | ✅ Done |
| 9 | Linux port — GCC build, CLI, CGI, FastCGI, MySQL+SQLite | ✅ Done |
| 9.1 | switch — Go-style, no break, multiple case, default | ✅ Done |
| 9.6 | Ubuntu + Plesk/Apache deployment documentation | ✅ Done |
| 9.8 | Error system — SourceLocation, stack trace, LookRuntimeError/ParseError | ✅ Done |
| 10.1 | Upload — request::file(), file:: module, magic byte, SHA-256 | ✅ Done |
| 10.0 | date:: module — now, format, parse, add, sub, diff, weekday | ✅ Done |
| 9.5 | PostgreSQL wire protocol v3 — MD5 auth, zero dependency | ✅ Done |
| Bug Fix | string comparison (< > <= >=), string::pad_left/pad_right, array::push/pop in the module | ✅ Done |
| Plesk | Plesk VPS deployment (AlmaLinux 8.10, live); session fix; direct mode | ✅ Done |
| 11 | struct (Go-style, default values, nested, clean json) + const iota (Go-style chain, member access) | ✅ Done |
| 12 | Concurrent runtime — ThreadPool + ConnPool + make_dispatch_copy(), --workers N | ✅ Done |
| 13 | --mode http — Event Loop (epoll Linux / select Windows), HttpServer, hot reload, Apache bypass | ✅ Done |
| 14 | parallel() + channel() — LOOK task + channel; fan-out, pipeline; 9/9 tests | ✅ Done |
| 15 | WebSocket — route("WS",...), ws:: module, RFC 6455 frame codec, SHA-1+Base64, WsRegistry broadcast-safe | ✅ Done |
| 16 | SSE — route("SSE",...), sse:: module (send/on/close/clients); timer:: — after/every/cancel, global TimerManager background thread | ✅ Done |
| 16.1 | --mode http bug fix — EPOLLOUT level-triggered; SIGPIPE ignore; 10min/4.7M requests/0 errors stress test | ✅ Done |
| 16.5 | Bytecode VM — register-based VM; compiler (AST→bytecode); 30/30 tests PASS; 7.8x compute speedup; alloc_seq fix; MAKE_CLOSURE capture-hint ordering; db::connect + channel() VM support | ✅ Done |
| 17 | Chat live — WS + broadcast + timer:: keepalive; 500/500 bots PASS; timer::cancel fix (instant erase+notify); 0 memory leak verified | ✅ Done |
| 17.1 | VM named-function bug fix — CALL goto + PrintStatement reg0 clobber + PARALLEL_CALL SEGV; concurrent-1000 PASS (11,008 RPS); ws-1000 PASS (1000 WS); memory-72h started | ✅ Done |
| 17.2 | --mode http stress test — 8,276 RPS router (VM+Apache bypass, c=100, 0 errors); c=1000 → 7,886 RPS; DB-bound ~380 RPS; interpreter→VM 3.3x gain | ✅ Done |
| 18 | Blog live — template::, CRUD, auth, file upload | ✅ Done |
| 18.5 | use "file.lk" file module system — function/const export, $var isolation, cycle protection, top-level guard; blog.lk refactored into 5 files (5/5 PASS) | ✅ Done |
| 19 | Security (session Secure+SameSite, /dev/urandom RNG, fixation, XSS, path traversal) + 22/24 tests PASS + VS Code Extension v0.1.0 | ✅ Done |
| 19.1 | Stdlib expansion: string::format, string::regex_match/replace/match_all, math::max/min variadic+array, array::flatten/chunk/zip | ✅ Done |
| 19.2 | http:: client — GET/POST/POST_JSON/PUT/PATCH/DELETE; OpenSSL (Linux) + Schannel (Windows); 10/10 PASS | ✅ Done |
| 19.3 | look.toml package-system architecture design + Native Extension API v1.1 draft + docs/package-system.md | ✅ Done |
| Sprint 3 | look test runner — look test, 10 assert functions, before_each/after_each, 29/29 PASS | ✅ Done |
| Sprint 4 | REPL — look repl, linenoise embed, auto print, multi-line blocks, :help/:vars/:clear/:exit, AST-ownership fix | ✅ Done |
| Sprint 5 | cache:: module — in-memory + TTL, thread-safe warm-start cache; 12/12 tests PASS | ✅ Done |
| Sprint 6 | queue:: module — named FIFO, persistent cross-request, 9/9 PASS | ✅ Done |
| Sprint 7 | jobs:: module — SQLite durable queue, retry, delayed jobs, dead-letter, worker/run; 23/23 PASS | ✅ Done |
| Sprint 8 | look install — download a GitHub zipball, extract to pkg/, look.lock SHA; zero dependency (miniz) | ✅ Done |
| v1.0-pre | look version/--version; jobs::recover() crash recovery; mail:: (Mailgun/SendGrid/Postmark); PostgreSQL BOOL fix; install.sh (Ubuntu+AlmaLinux, systemd+nginx automatic) | ✅ Done |
| v1.0 | Plesk extension (look-lang-1.0.0.zip), Windows IOCP (unlimited connections), Windows installer (install.bat), codlook.com landing page, Apache 2.0 open source, GitHub release | ✅ Done |
| v1.0.1 | Windows IOCP — AcceptEx + WSARecv + WSASend + PostQueuedCompletionStatus; c=500 844 RPS 0 errors; SelectEventLoop (64-connection limit) → unlimited connections | ✅ Done |
| v1.x Sprint | DKIM outbound signing (dkim.cpp); file-sandbox dangling-iterator fix (assert_in_file_root); SMTP relay protection (port 25 block); HTTP rate limiter (LOOK_RATE_LIMIT_RPM + LOOK_TRUSTED_PROXY); error:: core module (auto-load); task_* rename (goroutine_* → task_*); LOOK_PARALLEL_LIMIT | ✅ Done |
| v1.0 — Plesk workspace | Plesk extension full panel: sidebar (Dashboard/Applications/Logs/Documentation), in-browser code editor (Save & Redeploy), live per-domain monitor (CPU/RSS/PID/connections/uptime), journalctl log viewer, English UI; scripts via sudo /bin/bash (Plesk exec-bit stripping + post-install not-running issues overcome); state in the Plesk module var directory; live on a Plesk VPS (AlmaLinux 8.10 + Plesk Obsidian) | ✅ Done |
What's Next
| Goal | Contents | Status |
|---|---|---|
| Load-profile publication | Throughput/latency tables measured across different workloads | ⏳ Planned |
| Docker Hub | docker run codlook/look — 5-second install, no build | ✓ Live |
| Look Deploy | Managed cloud hosting — a look deploy command | ⏳ Future |
Production Test Results
The data below was obtained on an AlmaLinux 8.10 + Plesk Obsidian 18.0.76 + lk-fcgi server.
Live site: test.codlook.com —
Full record: docs/test-results.md
All Tests
| # | Test | Tool | Status | Date |
|---|---|---|---|---|
| 1 | Smoke Test (10 min) | curl | ✅ Done | Jun 9, 2026 |
| 2 | 72h Long Run Stress | stress-runner.sh | ✅ Done | Jun 10–13, 2026 |
| 3 | Memory Leak Monitoring | leak-monitor.sh | ✅ Done | Jun 10–13, 2026 |
| 4 | MySQL Recovery | mysql-recovery-test.sh | ✅ Done | Jun 10, 2026 |
| 5 | Concurrency — up to 1000 | run-tests-v2.sh | ✅ Done | Jun 13, 2026 |
| 6 | Connection Leak | run-tests-v2.sh | ✅ Done | Jun 13, 2026 |
| 7 | Crash Recovery | run-tests-v2.sh | ✅ Done | Jun 13, 2026 |
| 8 | MySQL Recovery v2 (db_check fix) | run-tests-v2.sh | ✅ Done | Jun 13, 2026 |
| 9 | Hot Reload (under load) | run-tests-v2.sh | ✅ Done | Jun 13, 2026 |
| 10 | Large Payload (100KB / 1MB / 10MB) | run-tests-v2.sh | ✅ Done | Jun 13, 2026 |
| 11 | Edge Case (boundary values) | run-tests-v2.sh | ✅ Done | Jun 13, 2026 |
| 12 | Unicode / Encoding / XSS | run-tests-v2.sh | ✅ Done | Jun 13, 2026 |
| 13 | Session Persistence | run-tests-v2.sh | ✅ Done | Jun 13, 2026 |
| 14 | Fuzz Test | Python fuzzer (custom script) | ✅ Done | Jun 2026 |
| 15 | Penetration (Pen Test) | curl / manual — Phase 19 | ✅ Done | Jun 2026 |
| 16 | Low-Resource VPS (1CPU / 1GB RAM) | Docker | ✅ Done | Jun 2026 |
| 17 | vm-stress (VM compute speedup) | look-chat /monitor/run | ✅ Done | Jun 16, 2026 |
| 18 | concurrent-1000 (5000 req, c=1000) | ab (ApacheBench) | ✅ Done | Jun 16, 2026 |
| 19 | ws-1000 (1000 concurrent WebSocket) | websocat v1.13.0 | ✅ Done | Jun 16, 2026 |
| 20 | memory-72h (72-hour RSS monitoring — chat/WS) | nohup bash + /proc/PID/status | ✅ Done | Jun 16–19, 2026 |
| 21 | Load ladder c=1k/5k/10k/20k | ab + kernel tuning | ✅ 0 errors | Jul 2026 |
| 22 | 1,000,000-request endurance (c=10k) | ab | ✅ 0 errors · RSS 28MB steady | Jul 2026 |
| 23 | ASan + UBSan HTTP fuzzing (16k+ iter) | fuzz_http.sh | ✅ 0 UB · 0 crashes | Jul 2026 |
| 24 | ThreadSanitizer data-race scan | tsan_run.sh | ✅ 0 races (1 found→fix) | Jul 2026 |
| 25 | Cross-contamination (20k, blocking+fiber) | integrity_test.sh | ✅ 0 data leakage | Jul 2026 |
| 26 | DoS / smuggling protections | raw HTTP + regression | ✅ 413/400 correct | Jul 2026 |
| 27 | Chunked Transfer-Encoding (RFC 7230) | raw HTTP + ASan fuzz | ✅ Works · 0 UB | Jul 2026 |
| 28 | Regression suite (9 production bugs) | run_regression.sh | ✅ 9/9 | Jul 2026 |
Security Hardening (July 2026)
Following an independent security review, the HTTP parser, DoS resistance, concurrency and data integrity were comprehensively hardened. Since the zero-dependency model makes every parser our responsibility, fuzzing and sanitizer rounds were considered mandatory.
| Area | Finding / Action | Verification |
|---|---|---|
| Memory safety | All HTTP/DB parsers fuzzed under ASan+UBSan | 16k+ iter, 0 UB |
| Data race | date::now tz race found → localtime_r + tzset() | TSan 1→0 race |
| Data leakage | Per-fiber/thread connection isolation verified | 20k requests, 0 contamination |
| Memory leak | Connection-pool double-release structurally prevented | 1.75M requests, RSS steady |
| DoS — body | LOOK_MAX_BODY_SIZE (10MB) → 413 | Verified live |
| Request smuggling | CL+TE together → 400 (RFC 7230) | Verified live |
| HTTP parser | Malformed CL → 400, full chunked TE support | 5k iter ASan, 0 UB |
| CI automation | ASan/UBSan fuzz + TSan on every push | .github/workflows/security.yml |
2 — 72-Hour Stability Test
| Metric | Result | Comment |
|---|---|---|
| Total HTTP requests | 766,032 | ~78 hours uninterrupted |
| Application-caused errors | 0 | Zero crashes, zero exceptions |
| Average response time | 136 ms | Unchanged from start to finish |
| Min / Max response | 30 ms / 45 s | Max = a momentary VPS network spike |
| RPS (stability test) | 3.50 | Sequential curl — a stability measurement, not a load test |
| RPS (warm start, with DB) | 418 | FastCGI warm start |
| RPS (warm start, no DB) | 2,150 | 26× vs CGI mode |
| Memory (RSS) — start | 17 MB | — |
| Memory (RSS) — after 66 hours | 74 MB | Plateaued, growth stopped |
| MySQL connection threads | 3–6 | Stable over 78 hours |
| look-fcgi PID | Unchanged | Not a single restart, not a single crash |
Network Anomalies (out of 766k requests)
| Code | Count | Actual Cause |
|---|---|---|
000 | ~20 | VPS/nginx momentary timeout — look-fcgi unaffected (PID proof) |
500 | 10 | The MySQL recovery test's deliberate stop step |
502 | 3 | Apache layer transient — look-fcgi unaffected |
| Application error | 0 | — |
3 — Memory Leak Monitoring (66 Hours)
| Time | RSS | VSZ | Comment |
|---|---|---|---|
| Start | ~17 MB | ~50 MB | — |
| Hour 24 | ~65 MB | ~80 MB | — |
| Hour 48 | ~66 MB | ~81 MB | Plateau began |
| Hour 66 | 74 MB | 90 MB | Plateau — growth stopped |
RSS plateaued in the 65–74 MB range. No linear growth — no memory leak could be shown.
4 — MySQL Recovery v1 (before the db_check fix)
| Step | Scenario | Expected | Actual |
|---|---|---|---|
| 1 | Normal operation | 200 OK | 200 ✅ |
| 2 | MySQL stopped | 503 graceful | 500 ⚠️ |
| 3 | MySQL restart, no hot reload | 503 | 200 ✅ auto reconnect |
| 4 | touch index.lk → hot reload | 200 OK | 200 ✅ |
The 500 in step 2: db_check() couldn't recognize the invalid handle as null. Fixed on Jun 13 with a SELECT 1 ping + try/catch — verified in #8.
5 — Concurrency — Up to 1000 Users
June 13, 2026 — parallel curl subprocesses (run-tests-v2.sh)
Home Page (no DB)
| Concurrent | Total Time | Avg Response | Errors |
|---|---|---|---|
| 50 | 11,882 ms | 237 ms | 0 |
| 100 | 22,459 ms | 224 ms | 0 |
| 200 | 44,278 ms | 221 ms | 0 |
| 500 | 109,379 ms | 218 ms | 0 |
| 1000 | 218,876 ms | 218 ms | 0 |
Route with DB (50,000-row products table)
| Concurrent | Total Time | Avg Response | Errors |
|---|---|---|---|
| 50 | 20,029 ms | 400 ms | 0 |
| 100 | 40,122 ms | 401 ms | 0 |
| 200 | 79,970 ms | 399 ms | 0 |
| 500 | 200,635 ms | 401 ms | 0 |
Mixed — 3 different endpoints at once
| Scenario | Total Requests | Avg Response | Errors |
|---|---|---|---|
| / + /menu + /search in parallel | 300 | 208 ms | 0 |
Even at 1000 concurrent users the average response stayed at 218ms — performance didn't degrade as load rose.
6 — Connection Leak
| Measurement | Value |
|---|---|
| Before 500 parallel requests | 8 connections |
| Immediately after 500 parallel requests | 8 connections |
| After a 5-second wait | 8 connections |
| Leak | Zero |
Phase 12 concurrent runtime: --workers N opens an N-connection ConnPool. Each request borrows a connection and returns it to the pool as soon as it finishes. 8 connections before 500 parallel requests → 8 after — zero leak.
7 — Crash Recovery
| Metric | Value |
|---|---|
| PID before / after | 17999 → 33492 |
| Status while stopped | 503 ✅ |
| After restart | 200 ✅ |
| Recovery time | < 3 seconds |
8 — MySQL Recovery v2 — db_check Fix Verification
| Step | Scenario | Expected | Actual |
|---|---|---|---|
| 1 | Normal operation | 200 | 200 ✅ |
| 2 | MySQL stopped | 503 | 503 ✅ FIX WORKED |
| 3 | MySQL restart, no hot reload | 200 (auto reconnect) | 200 ✅ |
| 4 | touch index.lk → hot reload | 200 | 200 ✅ |
A SELECT 1 ping + try/catch was added to db_check(). In fcgi_main.cpp, unhandled runtime errors were also updated from 500→503.
9 — Hot Reload (Under 200 Parallel Load)
| Metric | Value |
|---|---|
| touch index.lk during 200 parallel requests | PID unchanged ✅ |
| Status after reload | 200 ✅ |
| Dropped requests | Zero |
10–12 — Payload, Edge Case, Unicode / XSS
| Category | Scenario | Result |
|---|---|---|
| Large Payload | 100 KB JSON body | 404 — no crash ✅ |
| Large Payload | 1 MB JSON body | 404 — no crash ✅ |
| Large Payload | 10 MB JSON body | 404 — no crash ✅ |
| Edge Case | ID=0, ID=-1, ID=99,999,999 | 404 graceful ✅ |
| Edge Case | Empty query parameter | 403 — validator ✅ |
| Edge Case | Path traversal (../../../../etc/passwd) | 404 — router blocked ✅ |
| Edge Case | SQL injection path (1'--) | 404 — route didn't match ✅ |
| Edge Case | DELETE method | 404 — undefined ✅ |
| Unicode | Turkish characters (köfte) | 200 ✅ |
| Unicode | Emoji (🍕pizza) | 200 ✅ |
| XSS | <script>alert(1)</script> | 403 — blocked ✅ |
| SQLi encoded | %27;SELECT * FROM admins-- | 403 — blocked ✅ |
13 — Session Persistence
| Step | Scenario | Result |
|---|---|---|
| 1 | Login (admin@qrmenu.local) | 200 ✅ cookie received |
| 2 | Auth route with cookie | 200 ✅ |
| 3 | Same cookie after look-fcgi restart | 200 ℹ️ (401 expected) |
The session stayed valid after a look-fcgi restart — the session is file-based, so even though warm-start memory is cleared, the file is preserved. Added to the review list.
17 — VM Stress (compute speedup)
June 16, 2026 — LOOK_BYTECODE=1, 8 workers, --mode http
| Endpoint | Description | Interpreter | VM | Difference |
|---|---|---|---|---|
| /heavy | 10,000-iteration arithmetic loop | ~182 RPS | ~1,427 RPS | 7.8x ↑ |
| /light | 100-iteration string concat | ~8,829 RPS | ~15,653 RPS | 1.77x ↑ |
| /router | Pure routing (no DB) | ~8,715 RPS | ~8,521 RPS | ~1x |
vm-stress monitor test: ms=8000 (8 seconds of heavy compute → drops to 1 second on the VM). The server test was run via /monitor/run/vm-stress, monitor/vm-stress.json result PASS.
18 — concurrent-1000 (HTTP Load Test)
June 16, 2026 — ab (ApacheBench), localhost:9001, Apache bypass
| Metric | Value |
|---|---|
| Total requests | 5,000 |
| Concurrent connections | 1,000 |
| RPS | 11,008 |
| Failed requests | 0 |
| Max response time | 508 ms |
| Average response time | ~91 ms |
Localhost (zero network overhead), Apache bypass. /router endpoint (no DB) — pure LOOK dispatch + VM speed. /monitor/results endpoint: 5000 req, c=1000, 0 fail, 2018 RPS (with DB, 508ms max).
19 — ws-1000 (1000 Concurrent WebSocket)
June 16, 2026 — websocat v1.13.0 musl, localhost:9001/chat
| Metric | Value |
|---|---|
| Concurrent WS connections | 1,000 |
| Failed connections | 0 |
| Server status | Active — ws::clients() worked |
| broadcast test | Works |
| Server crash | None |
1000 parallel bash subprocesses, each connected to WS /chat via websocat and sent a message. All connections were established. Broadcast stayed active. The server didn't crash.
20 — memory-72h (72-Hour RSS Monitoring)
Started June 16, 2026 23:31 — ended: June 19, 2026 ~02:31
| Metric | Value |
|---|---|
| Starting RSS | 97 MB (look-fcgi-chat PID=79377) |
| Sampling interval | 30 minutes (144 samples) |
| Failure threshold | 100 MB delta |
| Status | ✅ Done |
| End date | June 19, 2026 ~02:31 |
| Result | Zero real leak — RSS stayed steady |
Result: over the 72-hour test, RSS stayed steady around its starting value. glibc high-water-mark behavior was confirmed — RSS doesn't drop after a connection closes (glibc design) but it doesn't grow either. Real memory leak: zero.
14 — Fuzz Test
June 2026 — a custom Python fuzzer (no radamsa, Windows environment)
| Target | Rounds | Crashes | Hangs | Result |
|---|---|---|---|---|
| Parser / Interpreter (CLI) | 10,000 | 0 | 0 | ✅ PASS |
| HTTP live endpoint | — | 0 | 0 | ✅ PASS |
During fuzzing a stoi argument out of range bug was found — a large integer payload (10000000041) exceeded the int limit.
Fixed with std::stoll + a bounds check. After the fix: 5/5 = 200 OK, no crash.
15 — Penetration (Pen Test)
June 2026 — curl / manual — together with the Phase 19 security improvements
| Category | Test | Result |
|---|---|---|
| SQL Injection | 1'; DROP TABLE products; -- | ✅ Blocked — parameterized query |
| XSS | <script>alert(1)</script> | ✅ 403 / html::escape |
| Path Traversal | ../../../../etc/passwd | ✅ weakly_canonical() + root confinement |
| Session Fixation | Same SID before and after login | ✅ SID renewed via destroy()+start() |
| Auth Bypass | Unauthorized access to an admin route | ✅ 401 — session check |
| Cookie Security | Set-Cookie header inspection | ✅ Secure; SameSite=Lax added |
| RNG Quality | Session ID entropy | ✅ /dev/urandom (Linux) / rand_s (Windows) |
The pen-test findings were implemented as 7 security fixes in Phase 19. 22/24 security tests PASS — 2 items were added to the watch list as "known, low priority".
16 — Low-Resource VPS (1 CPU / 1 GB RAM)
June 2026 — Docker container, --cpus=1 --memory=1g
| Metric | Value |
|---|---|
| CPU | 1 vCPU (--cpus=1) |
| RAM | 1 GB (--memory=1g) |
| look-fcgi workers | 2 (minimum) |
| look-fcgi RSS | Stable — no OOM |
| Crash | 0 |
| Service status | ✅ PASS — ran stably on 1 CPU / 1 GB |
Simulates a shared / entry-level hosting environment. look-fcgi runs stably with minimal resources;
--workers 2 keeps the memory footprint low. The OOM killer wasn't triggered.
Frequently Asked Questions
The technical questions developers ask when they first see LOOK, and the answers.
Performance
Isn't a tree-walk interpreter slow?
The "tree-walk = slow" cliché is true for models that re-parse on every request.
In LOOK, thanks to FastCGI Warm Start the code is parsed once and the AST stays in memory.
Each request only runs dispatch → callback; the parse cost is zero.
Hot paths also run on the bytecode VM — 10,000+ RPS on a single core on a direct port.
Moreover, in web apps the bottleneck isn't the interpreter: 70–80% of response time is the DB query, 10–15% is network/I/O. The interpreter's share is 5–10%. LOOK solves the real bottleneck (the DB) with a C++ connection pool. Phase 12 added ThreadPool + ConnPool — N worker threads run concurrently. Field proof: 700k+ requests in 72 hours, a steady 134 ms average response, 0 CPU locks.
3.5 RPS is very low. What happens in a real load test?
The 72-hour test is a stability test — it answers "does the PID change, does memory leak,
does it crash", not how high the RPS is. The sequential curl loop deliberately runs single-threaded.
For a real load test, values measured with wrk: 418 RPS with DB, 2,150 RPS without DB (26× vs CGI).
Security
Code can run in string interpolation. Is data from the DB eval'd? (Injection risk)
No. String interpolation {...} only works in string literals in the source code,
at parse time. At runtime a {...} inside a variable stays as plain text and is
not evaluated a second time.
# raw data from the DB
$db_value = file::read("/tmp/test.txt") # content: {1+1}
print($db_value) # output: {1+1} ← not evaluated
$s = "Value: " . $db_value # output: Value: {1+1}
$s2 = "Value: {$db_value}" # output: Value: {1+1} ← double quotes didn't evaluate it either
No string from the DB, a file or user input can get through this filter. Code Injection / RCE is theoretically impossible.
The documentation has a "dangling pointer" warning. Can the server crash?
That warning is an internal architecture rule for the core developers, not for the developers who use the language
(that WarmApp::setup_out must be a WarmApp member, not a local).
A developer writing code on LOOK never touches a pointer; memory management is entirely handled by the runtime.
Proof: 72 hours, 700k+ requests, the PID never changed.
Architecture
In FastCGI, $conn stays global. If the connection drops on MySQL wait_timeout, does the app blow up?
LOOK solves this scenario with the db_check($conn) pattern + automatic reconnect.
In the MySQL recovery test, MariaDB was deliberately stopped: when MySQL restarted, look-fcgi reconnected on its own
without a hot reload being triggered (Step 3 → 200). And db_check() returns
503 when there's no connection; the app never crashes.
No OOP. Don't large projects turn into spaghetti code?
LOOK has no classes — on purpose. Code organization rests on file-based modules with use module_name;
and data grouping with struct. This is the same as Go's struct + package approach.
It doesn't carry the inheritance chains and interface boilerplate that OOP makes necessary.
An enterprise-scale QR Menu application (29 routes, 50k+ products) was written with this model and runs in production.
The XAMPP/Apache model is dated. Does it run in Docker and Kubernetes?
LOOK doesn't depend on Apache or XAMPP. look-fcgi hosts its own TCP socket server
(port 9000). It can be packaged with a one-line Dockerfile and run on Kubernetes.
XAMPP is an option for easy setup on a developer's machine — not a requirement.
Linux binaries are built separately: Docker build images are ready for Ubuntu 24.04 and AlmaLinux 8.
No package manager. How do you add a third-party library?
The most-needed modules (MySQL, SQLite, PostgreSQL, auth, validator, date, file, upload)
are embedded in the core with no external dependency. They activate with a single line like use math;.
For community packages, look install is part of v1.0.0:
the look install github.com/user/repo command downloads a zipball from GitHub,
extracts it to a pkg/ folder and locks it with look.lock. No registry, no config.
Language Design
Why the $ sign? Modern languages don't have it.
A fair criticism. The $ prefix is kept to distinguish a variable from an expression
in string interpolation. This is an open discussion for the v1.0 goal; since the lexer is written from scratch in C++23,
there's no technical obstacle.
Why the clumsiness of use ($conn)? Isn't there lexical scope?
In the FastCGI warm-start architecture, global scope runs in the setup phase; automatic access by closures to those variables
is risky from a data-isolation standpoint.
use ($conn) deliberately makes dependencies visible — which route depends on what is
immediately clear when reading the code. The lexical-scoping discussion is an open item on the v1.0 roadmap.
Why no mixed arrays? Won't it be a problem when parsing JSON?
JSON comes into LOOK as an assoc array: $data = json::decode($body);
All keys are strings, all values are mapped to LOOK types (int, float, string, bool, null, array, assoc).
Nested arrays inside JSON are resolved the same way. Because there are no mixed arrays, the memory layout is
predictable and CPU-cache-friendly.
Does LOOK have struct?
Yes — added in Phase 11. Go-style, no inheritance, no methods, data only:
struct User {
name
age
email
}
$u = User{name: "Ali", age: 30, email: "ali@example.com"}
print($u.name) # Ali
$u.name = "Mehmet" # update
# json::encode gives clean output — the runtime tag doesn't leak
print(json::encode($u))
# {"name":"Mehmet","age":30,"email":"ali@example.com"}
OOP (class/inheritance) is deliberately absent. Behavior is added with separate functions. Details: the struct section.
Is LOOK a concurrent language? When is parallel() used?
LOOK is concurrent at two levels. At the runtime level: look-fcgi automatically runs each HTTP request on a separate worker thread — the developer doesn't see it, the code doesn't change.
At the language level: with parallel() + channel() you can do multiple things in parallel within a single request (for example 3 DB queries at once).
# An ordinary route — the code doesn't change, the runtime runs it in parallel
route("GET", "/stats", function() use ($conn) {
# Sequential within a single thread
$u = db::query($conn, "SELECT count(*) as n FROM products", [])[0]["n"]
$k = db::query($conn, "SELECT count(*) as n FROM categories", [])[0]["n"]
print(json::encode(["products" => $u, "categories" => $k]))
})
# With parallel() — the two queries run concurrently
route("GET", "/stats-fast", function() use ($conn) {
$r = channel(2)
parallel(function() use ($r, $conn) {
send($r, db::query($conn, "SELECT count(*) as n FROM products", [])[0]["n"])
})
parallel(function() use ($r, $conn) {
send($r, db::query($conn, "SELECT count(*) as n FROM categories", [])[0]["n"])
})
$u = receive($r) $k = receive($r)
print(json::encode(["products" => $u, "categories" => $k]))
})
Do you need Apache for WebSocket?
No. look-fcgi --mode http runs its own TCP server — Apache bypass.
All HTTP/1.1 requests, including WebSocket, are handled directly by look-fcgi.
In production you can put an Nginx reverse proxy in front, but it isn't required.
FastCGI mode (--mode fcgi) doesn't support WebSocket — Apache doesn't support
forwarding an HTTP Upgrade over the FastCGI protocol.
# Starting — completely independent of Apache
look-fcgi --mode http --port 8090 --workers 4 index.lk
# Both an HTTP and a WS route in the same index.lk
route("GET", "/api/data", function() use ($conn) {
print(json::encode(["ok" => true]))
})
$hub = channel()
route("WS", "/live", function($ws) use ($hub) {
ws::on($ws, "message", function($d) use ($hub) {
ws::broadcast($d)
})
})
Does LOOK have enum? How do I define constant values?
There's no enum keyword. Instead, use const + iota:
const {
PENDING = iota # 0
ACTIVE # 1
INACTIVE # 2
}
if ($order.status == ACTIVE) {
print("Active")
}
A top-level const is global — accessible from all closures without use.
Details: the const / iota section.
Example 1 — Hello World
Writing a web app with LOOK needs no framework setup. One file, one command.
hello.lk
# hello.lk — 3 lines, a fully working web app
route("GET", "/", function() {
print("Hello World!")
})
Running
# CLI — run directly
look hello.lk
# FastCGI — behind Apache/Nginx
look-fcgi --port 9000
# Standalone HTTP (without Apache)
look-fcgi --mode http --port 8080
JSON response
route("GET", "/", function() {
response::header("Content-Type", "application/json")
print(json::encode([
"ok" => true,
"message" => "Hello World!",
"language" => "LOOK"
]))
})
route("404", function() {
response::status(404)
print(json::encode(["ok" => false, "error" => "Not found"]))
})
Response with a URL parameter
route("GET", "/hello/{name}", function($name) {
print("Hello, {$name}!")
})
# GET /hello/Ali → "Hello, Ali!"
route() call dispatches automatically when the script ends — you don't need to call run() or serve().
Example 2 — REST API + Database
A full CRUD API with MySQL/SQLite/PostgreSQL. Parameterized queries, type conversion, error handling.
api.lk
# api.lk — Product REST API
$conn = db::connect("mysql://root:@127.0.0.1/shop")
function json_ok($data) {
response::header("Content-Type", "application/json")
print(json::encode(["ok" => true, "data" => $data]))
}
function json_error($message, $code = 400) {
response::status($code)
response::header("Content-Type", "application/json")
print(json::encode(["ok" => false, "error" => $message]))
}
# GET /products — all products
route("GET", "/products", function() use ($conn) {
$rows = db::query($conn, "SELECT * FROM products ORDER BY id DESC", [])
json_ok($rows)
})
# GET /products/{id} — a single product
route("GET", "/products/{id}", function($id) use ($conn) {
$rows = db::query($conn, "SELECT * FROM products WHERE id = ?", [int($id)])
if (count($rows) == 0) { json_error("Product not found", 404) return }
json_ok($rows[0])
})
# POST /products — add a new product
route("POST", "/products", function() use ($conn) {
$body = request::json()
$name = $body["name"] ?? ""
$price = float($body["price"] ?? 0)
if ($name == "" || $price <= 0) { json_error("name and price are required") return }
db::exec($conn, "INSERT INTO products (name, price) VALUES (?, ?)", [$name, $price])
json_ok(["id" => db::last_id($conn), "name" => $name, "price" => $price])
})
# PUT /products/{id} — update
route("PUT", "/products/{id}", function($id) use ($conn) {
$body = request::json()
$name = $body["name"] ?? null
$price = $body["price"] ?? null
if ($name == null && $price == null) { json_error("No field to update") return }
db::exec($conn, "UPDATE products SET name=COALESCE(?,name), price=COALESCE(?,price) WHERE id=?",
[$name, $price, int($id)])
json_ok(["updated" => db::affected($conn) > 0])
})
# DELETE /products/{id} — delete
route("DELETE", "/products/{id}", function($id) use ($conn) {
db::exec($conn, "DELETE FROM products WHERE id = ?", [int($id)])
json_ok(["deleted" => db::affected($conn) > 0])
})
Database connection — with .env
# .env
DB_HOST=127.0.0.1
DB_NAME=shop
DB_USER=root
DB_PASS=
# api.lk — read from .env
$conn = db::connect(
"mysql://" . env("DB_USER") . ":" . env("DB_PASS") .
"@" . env("DB_HOST") . "/" . env("DB_NAME")
)
Supported databases
| Database | DSN |
|---|---|
| MySQL / MariaDB | mysql://user:pass@host/db |
| SQLite | sqlite://./database.db |
| SQLite in-memory | sqlite://:memory: |
| PostgreSQL | postgres://user:pass@host/db |
Example 3 — WebSocket Chat
Real-time multi-user chat. Broadcast, keepalive timer, connection management.
chat.lk
# chat.lk — Real-time broadcast chat
# Runs with --mode http (FastCGI doesn't support WebSocket)
$hub = channel() # all messages pass through this channel
# Home page — chat UI
route("GET", "/", function() {
print(`<!DOCTYPE html>
<html><head><title>LOOK Chat</title></head>
<body>
<div id="log" style="height:400px;overflow:auto;border:1px solid #ccc;padding:8px"></div>
<input id="msg" placeholder="Type a message..." style="width:80%">
<button onclick="send()">Send</button>
<script>
const ws = new WebSocket("ws://" + location.host + "/ws")
const log = document.getElementById("log")
ws.onmessage = e => {
const d = JSON.parse(e.data)
log.innerHTML += "<p><b>" + d.from + "</b>: " + d.msg + "</p>"
log.scrollTop = log.scrollHeight
}
function send() {
const input = document.getElementById("msg")
ws.send(JSON.stringify({msg: input.value}))
input.value = ""
}
</script>
</body></html>`)
})
# WebSocket endpoint
route("WS", "/ws", function($ws) use ($hub) {
# Receive from the hub → send to this client
parallel(function() use ($ws, $hub) {
while (true) {
$msg = receive($hub)
if ($msg == null) { break }
ws::send($ws, $msg)
}
})
# On an incoming message, broadcast to the hub
ws::on($ws, "message", function($data) use ($hub) {
$parsed = json::decode($data)
$packet = json::encode([
"from" => request::ip(),
"msg" => $parsed["msg"]
])
send($hub, $packet)
})
# A keepalive ping every 30 seconds
$ping = timer::every(30000, function() use ($ws) {
ws::send($ws, json::encode(["ping" => true]))
})
ws::on($ws, "close", function() use ($ping) {
timer::cancel($ping)
log::info("Connection closed. Active: " . ws::clients())
})
})
route("GET", "/status", function() {
print(json::encode(["ws_clients" => ws::clients()]))
})
Starting
look-fcgi --mode http --port 8080 --workers 8
SSE (Server-Sent Events) alternative
# One-way data stream — simpler, over HTTP
route("SSE", "/events", function($sse) {
$tick = timer::every(2000, function() use ($sse) {
$ok = sse::send($sse, json::encode(["time" => date::now()]), "tick")
if (!$ok) { timer::cancel($tick) return } # connection closed
})
sse::on($sse, "close", function() use ($tick) {
timer::cancel($tick)
})
})
Example 4 — Blog (Modular Structure)
Split large apps into files with use "file.lk". Explicit scope — no global state sharing, only functions and constants are exported.
File structure
blog/
blog.lk ← bootstrap (~50 lines)
helpers.lk ← shared functions
routes/
auth.lk ← /admin/login, /admin/logout
public.lk ← /, /post/{slug}
admin.lk ← CRUD management panel
views/
layout/base.html ← main template
public/index.html
public/post.html
admin/panel.html
blog.lk — bootstrap
use "helpers.lk"
$conn = db::connect(
"mysql://" . env("DB_USER") . ":" . env("DB_PASS") .
"@" . env("DB_HOST") . "/" . env("DB_NAME")
)
$tpl = env("VIEWS_DIR", "/var/www/blog/views")
use "routes/auth.lk"
register_auth_routes($conn, $tpl)
use "routes/public.lk"
register_public_routes($conn, $tpl)
use "routes/admin.lk"
register_admin_routes($conn, $tpl)
route("404", function() use ($tpl) {
response::status(404)
print(template::render($tpl . "/404", ["title" => "Not found"]))
})
helpers.lk — shared functions
use template
use auth
function db_check($conn) {
if ($conn == null) {
response::status(503)
print(json::encode(["ok" => false, "error" => "Database unavailable"]))
return false
}
return true
}
function admin_check() {
session::start()
$id = session::get("admin_id")
if ($id == null) {
response::redirect("/admin/login")
return false
}
return true
}
routes/public.lk
# $conn isn't global — it arrives as an explicit parameter
function register_public_routes($conn, $tpl) {
route("GET", "/", function() use ($conn, $tpl) {
$posts = db::query($conn,
"SELECT id, title, slug, summary, created_at FROM posts
WHERE published = 1 ORDER BY id DESC LIMIT 10", [])
print(template::render($tpl . "/public/index", [
"title" => "Blog",
"posts" => $posts
]))
})
route("GET", "/post/{slug}", function($slug) use ($conn, $tpl) {
$rows = db::query($conn,
"SELECT * FROM posts WHERE slug = ? AND published = 1", [$slug])
if (count($rows) == 0) { response::status(404) return }
print(template::render($tpl . "/public/post", [
"title" => $rows[0]["title"],
"post" => $rows[0]
]))
})
}
Template — views/layout/base.html
<!DOCTYPE html><html><head>
<title>{$title}</title>
</head><body>
<header><a href="/">Blog</a></header>
{#block "content"}{/block}
<footer>Made with LOOK</footer>
</body></html>
<!-- views/public/index.html -->
{#extends "views/layout/base"}
{#block "content"}
<main>
{#each $posts as $y}
<article>
<h2><a href="/post/{$y.slug}">{$y.title}</a></h2>
<p>{$y.summary}</p>
</article>
{#empty}
<p>No posts yet.</p>
{/each}
</main>
{/block}
use "file.lk" only exports functions and constants. Variables like $conn aren't shared — you must pass them as explicit parameters. This prevents the "global chaos" caused by hidden dependencies.
Example 5 — File Upload
Magic-byte validation, secure storage with SHA-256, type restrictions. LOOK's built-in protection layers for secure upload.
upload.lk
use file
$conn = db::connect(env("DB_DSN"))
# Upload form
route("GET", "/upload", function() {
print(`<!DOCTYPE html>
<html><body>
<form method="POST" action="/upload" enctype="multipart/form-data">
<input type="file" name="file" accept="image/*">
<input type="text" name="title" placeholder="Title">
<button type="submit">Upload</button>
</form>
</body></html>`)
})
# Handle the upload
route("POST", "/upload", function() use ($conn) {
$title = request::post("title") ?? ""
# request::file() does magic-byte + MIME + size checking
try {
$file = request::file("file", [
"max_size" => 5242880, # 5 MB
"allow_mime" => [
"image/jpeg",
"image/png",
"image/webp"
]
])
} catch ($e) {
response::status(400)
print(json::encode(["ok" => false, "error" => $e]))
return
}
if ($file == null) {
print(json::encode(["ok" => false, "error" => "No file selected"]))
return
}
# file::store() → saves safely outside the web root, gives it a SHA-256 name
$record = file::store($file, "images")
$url = "/uploads/images/" . $record["name"]
db::exec($conn,
"INSERT INTO files (title, url, size, mime) VALUES (?,?,?,?)",
[$title, $url, $file["size"], $file["mime"]])
print(json::encode([
"ok" => true,
"url" => $url,
"size" => $file["size"],
"mime" => $file["mime"]
]))
})
# List uploaded files
route("GET", "/files", function() use ($conn) {
$list = db::query($conn, "SELECT * FROM files ORDER BY id DESC", [])
print(json::encode(["ok" => true, "files" => $list]))
})
Security layers
| Layer | What It Does |
|---|---|
| Magic-byte check | Validates the real type by looking at the file content, not the extension. Blocks attempts to hide a .php extension as .jpg. |
| MIME allow-list | Files whose type isn't in the allow_mime array are rejected. |
| Size limit | max_size in bytes — an exception is thrown if the limit is exceeded. |
| SHA-256 naming | file::store() renames the file with its SHA-256 hash. The original name doesn't remain on the system. |
| Web root protection | The storage directory is kept outside the web root. Direct URL access isn't possible. |
| SVG blocked (default) | Since SVG is an XSS vector, it's rejected by default. Enabled with "allow_svg": true. |
request::file() return value
$file["name"] # original file name
$file["mime"] # real MIME type (from the magic byte)
$file["size"] # size in bytes
$file["temp_path"] # temporary file path — pass it to file::store()
# file::store() return value
$record["name"] # new file name with the SHA-256 hash
$record["path"] # full path on disk