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.

C++23 runtime Zero dependency FastCGI warm start N-thread concurrent parallel() + channel() WebSocket RFC 6455 SSE route("SSE",...) timer:: scheduler Apache / XAMPP MySQL · SQLite · PostgreSQL Linux / Ubuntu / AlmaLinux Bytecode VM — default engine Chat live — 500/500 bots PASS memory-72h — zero leak verified concurrent-1000: 11008 RPS ws-1000: 1000 WS template:: engine use "file.lk" module system Blog live — looktest.tobiyo.com.tr/blog v1.0.0

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

FeatureDetail
RuntimeC++23 — register-based bytecode VM (default engine, ~41–51× faster on the CLI) with a tree-walk interpreter as a safety-net fallback
Web modeFastCGI — port 9000 (production) · HTTP mode (WebSocket) · CGI (fallback)
DatabaseMySQL · SQLite · PostgreSQL — zero dependency, wire protocol
InstallCopy the binary, register it with Apache, run
GCReference 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)

  1. Download look-windows-x64.zip from GitHub Releases
  2. Copy look-fcgi.exe and look-cgi.exe into C:\xampp\cgi-bin\
  3. Add to C:\xampp\htdocs\.htaccess: Options +ExecCGI
  4. Create C:\xampp\htdocs\index.lk (the example below)
  5. Start look-fcgi: look-fcgi.exe --port 9000
  6. Start Apache, open http://localhost/

Detailed guide: xampp/setup.md

⊞ Linux (Ubuntu / AlmaLinux)

  1. Download the binary for your platform from GitHub Releases
  2. Copy the binary and give it the execute bit:
    cp look-fcgi /opt/look/ && chmod +x /opt/look/look-fcgi
  3. Create an index.lk file in your app directory
  4. Write a systemd service (see the FastCGI section)
  5. Add the Apache .htaccess rewrite rules (see the Apache section)
  6. systemctl start look-fcgicurl 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

StepWhat you'll learnSectionTime
1 — Language basics Variables, operators, strings, if/for/switch, functions, closures VariablesFunctions ~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 SessionAuth ~30 min
5 — Templates & file module template::render, layout inheritance, splitting code with use "file.lk" TemplateFile Module ~45 min
6 — Production FastCGI warm start, --workers N, hot reload, log::, .env FastCGIConcurrent ~1 hour
7 — Real-time WebSocket route("WS",...), SSE route("SSE",...), timer::every WebSocketSSE ~1 hour
8 — Background jobs parallel() + channel(), jobs::push/run/recover, cache::, queue:: ParallelJobs ~1 hour

Binary Selection

What you wantBinary to useCommand
Production web app (Apache/nginx)look-fcgilook-fcgi --port 9000 --workers 8
WebSocket / SSE / real-timelook-fcgi --mode httplook-fcgi --mode http --port 8090 --workers 4 index.lk
CLI script / language testinglklk script.lk
Syntax + undefined-call check (without running)lk --checklk --check script.lkOK or CHECK <line> <column> <message>
Interactive shelllk repllk repl
Run testslk testlk test tests/ --verbose
Install a packagelk installlk 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

BinaryPurposeStatus
look-fcgi.exeFastCGI — production (port 9000)✅ Active
look-cgi.exeApache CGI — fallback mode✅ Stable
look.exeCLI interpreter✅ Stable

Source Files (cpp/src/)

FileContents
lexer.cppTokenizer, $ prefix, all token types
parser.cppPrecedence-aware parser, AST nodes, SourceLocation
interpreter.cppTree-walk interpreter, GC, route() setup_mode, dispatch_routes(), make_dispatch_copy()
stdlib.cppmath:: string:: type:: array:: — all standard modules
extra_stdlib.cppenv() config() runtime::stats()
web.cpproute() request:: response:: json:: session:: cookie:: — HTTP primitives
web_stdlib.cppdb:: auth:: validator:: html:: · ConnPool (per-DSN connection pool)
mysql_client.cppMySQL wire protocol — Winsock/POSIX, zero dependency
sqlite_client.cppSQLite driver — sqlite3 amalgamation, WAL+FK by default
postgres_client.cppPostgreSQL wire protocol v3 — MD5 auth, lastval(), zero dependency
logger.cpplog:: — daily rotation, Windows FILE_SHARE_WRITE
file_stdlib.cppfile:: — read/put/append/exists/remove/size/store · upload security layer
date_stdlib.cppdate:: — now/today/format/parse/add/sub/diff/weekday/week
fcgi_main.cppFastCGI entry — WarmApp, warm start, hot reload, ThreadPool, concurrent dispatch
fcgi_protocol.cppFastCGI TCP wire protocol
http_main.cpp--mode http entry point — HttpApp, hot reload, ws_handler, sse_handler
http_server.cppHTTP/1.1 parser, WorkerPool, WS upgrade, SSE upgrade, async_read/write
event_loop.cppAbstract EventLoop — epoll (Linux) / IOCP (Windows)
websocket.cppSHA-1, Base64, RFC 6455 frame codec, WsConnection, WsRegistry
sse.cppSseConnection, SseRegistry — SSE frame sending, disconnect detection
timer_stdlib.cppTimerManager singleton — background thread, after/every/cancel, interpreter binding
compiler.cppAST → FunctionProto bytecode compiler — RegisterAllocator, alloc_seq(), short-circuit &&/||, closure capture hints, try/catch TRY_PUSH/POP
vm.cppRegister-based VM — switch(opcode) dispatch, CallFrame stack, TryCatchEntry, PARALLEL_CALL (thread detach), CHAN_*/WS_*/SSE_* opcodes
cgi_main.cppCGI entry — fallback mode
main.cppCLI 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 httpCLI (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

FunctionDescription
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)

SequenceMeaning
\nNewline
\tTab
\rCarriage return
\b \f \0Backspace · form feed · null
\\Backslash
\"Double quote
\uXXXXUnicode 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")
    # ...
})
FunctionDescription
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

TypeWhenOutput
LookParseErrorSyntax error — at compile time[PARSE ERROR] index.lk:12:5 — expected '}'
LookRuntimeErrorRuntime 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

FunctionDescriptionReturns
error::new($type, $msg [,$code])Throw a structured error— (throws)
error::is($e, $type)Type check inside a catch blockbool
error::message($e)Safely get the error messagestring
error::code($e)Get the HTTP/application error codeint

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
Design Decisions
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
Scope Rule: constants are valid in the scope they're defined in. A top-level 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.

FunctionDescription
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::

FunctionDescriptionExample
request::method()HTTP method"GET", "POST"...
request::get("k")Query string parameter?q=burger → "burger"
request::post("k")Form POST fieldform 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 combinedassoc 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::

FunctionDescription
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
    # ...
})
FunctionDescription
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 VarDefaultDescription
LOOK_SESSION_DRIVERfilefile (single server) or redis (multi-server)
LOOK_REDIS_URLredis://127.0.0.1:6379/0redis://[:pass@]host:port[/db] format
LOOK_SESSION_TTL3600Session lifetime in seconds (Redis EXPIRE + file-compatible)
LOOK_SESSION_DIR/tmpSession 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 TypeExamplesLOOK Type
INT, TINYINT, BIGINT, MEDIUMINT, SMALLINT, YEARid, qty, active, yearint
FLOAT, DOUBLE, DECIMALprice, rate, tax_ratefloat
VARCHAR, TEXT, CHAR, ENUMname, title, slug, statusstring
DATE, DATETIME, TIMESTAMPcreated_at, date, updatedstring (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

FunctionDescription
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::

FunctionLevelUse
log::info("msg")INFONormal-flow information
log::error("msg")ERRORError conditions
log::warn("msg")WARNWarnings
log::debug("msg")DEBUGDuring development
log::query($sql, $ms)QUERYSlow-query logging
log::memory("msg")MEMORYMemory-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.

FunctionDescription
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.

FieldTypeDescription
okbooltrue if valid; false on error
lineintError line (0 when ok)
colintError column
msgstring"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.

FunctionDescription
runtime::stats()Returns the interpreter state as an assoc array
runtime::gc()Trigger garbage collection — returns "ok"

stats() Output

FieldTypeDescription
uptime_secintSeconds elapsed since process start
request_countintTotal requests handled by this process
route_countintNumber of registered routes
working_mbfloatWorking Set memory usage (MB)
private_mbfloatPrivate 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

FunctionDescriptionExample
math::sqrt($n)Square rootsqrt(144) → 12
math::pow($b, $e)Exponentiationpow(2,10) → 1024
math::abs($n)Absolute valueabs(-5) → 5
math::floor($n)Round downfloor(3.9) → 3
math::ceil($n)Round upceil(3.1) → 4
math::round($n)Roundround(3.5) → 4
math::max($a, $b)The larger of two valuesmax(3,7) → 7
math::max($a, $b, $c, ...)Multiple arguments — the largestmax(3,7,2,9) → 9
math::max([$arr])The largest in an arraymax([5,3,8]) → 8
math::min($a, $b)The smaller of two valuesmin(3,7) → 3
math::min($a, $b, $c, ...)Multiple arguments — the smallestmin(3,7,2,9) → 2
math::min([$arr])The smallest in an arraymin([5,3,8]) → 3
math::pi()The π constant3.14159...
math::random($min,$max)Random integerrandom(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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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],...]
FunctionDescription
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.

FunctionDescription
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:

FieldTypeDescription
statusintHTTP status code (200, 404, 500, ...)
bodystringResponse body
headersassocResponse headers (lowercase keys)
errorstring | nullNetwork 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.

FunctionDescription
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.

FunctionDescriptionReturns
crypto::sha256($data)SHA-256 digesthex string (64 characters)
crypto::hmac_sha256($data, $key)HMAC-SHA256 signaturehex string
crypto::hmac_sha256_raw($data, $key)HMAC-SHA256 — raw bytes (for JWT signing)binary string
crypto::base64_encode($s)Standard Base64 encodestring
crypto::base64_decode($s)Standard Base64 decodestring
crypto::base64url_encode($s)URL-safe Base64, no padding (JWT)string
crypto::base64url_decode($s)URL-safe Base64 decodestring
crypto::hex_encode($s)Hex encodestring
crypto::hex_decode($hex)Hex decodestring
crypto::uuid()Generate a UUID v4 (RFC 4122)string
crypto::random_bytes($n)n cryptographic random byteshex string
crypto::random_string($n)n bytes of entropy — URL-safe Base64string
crypto::constant_compare($a, $b)Timing-safe string comparisonbool
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

FunctionDescriptionReturns
jwt_sign($payload, $secret [, $opts])Generate an HS256 JWT tokenstring (token)
jwt_verify($token, $secret)Verify the signature + check expirationassoc (payload) or null
jwt_decode($token)Read the payload without verifying the signature (debug)assoc or null

jwt_sign — Options ($opts)

FieldTypeDescription
expintValidity in seconds (e.g. 3600 = 1 hour)
issstringIssuer — who generated the token
audstringAudience — 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

FunctionDescriptionReturns
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

FunctionDescription
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

FunctionDescription
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_ROOTBehaviour
unset (default)Restricted to the current working directory — safe out of the box.
/path/to/dirRestricted 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.

CheckDescription
Magic byteDetects the real type from the first bytes of the file content — the extension isn't trusted
MIME validationChecked against the allow_mime list
Size limitDefaults to 1MB if unspecified, deliberately raised
Random nameThe user's file name is never kept — a SHA-256-based hex name
SVG permissionSVG is rejected unless allow_svg: true is explicitly set
Web root protectionfile::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".

FunctionDescriptionExample
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 → int1749470520
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 → stringadd 7 days
date::sub($date, $n, $unit)Subtract → stringsubtract 1 month
date::diff($t1, $t2, $unit)Difference → int205 days
date::weekday($date)Day of the week → int (1=Mon, 7=Sun)2
date::week($date)ISO week number → int24
date::is_valid($date)Is it a valid date? → boolfalse
date::from_timestamp($ts)Unix → "YYYY-MM-DD HH:MM:SS""2026-06-09..."

Format Tokens

TokenDescriptionExample
dDay 01-3109
jDay 1-31 (no leading zero)9
mMonth 01-1206
nMonth 1-12 (no leading zero)6
Y4-digit year2026
y2-digit year26
HHour 00-2314
iMinute 00-5932
sSecond 00-5900
DShort day nameTue
lLong day nameTuesday
MShort month nameJun
FLong month nameJune
NISO day of week (1=Mon, 7=Sun)2
tNumber of days in the month30
UUnix timestamp1749470520

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

FunctionDescription
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

DirectiveDescription
{$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

RuleBehavior
Scope isolationOnly function and const are exported. $var variables aren't shared.
Circular includea.lk → b.lk → a.lkLookRuntimeError: Circular include
Top-level onlyuse "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"
SourceC++ built-in.lk source file
ExportedModule functionsOnly function + const
$var sharingNo (isolated env)
Cycle protectionYes (included_files_ set)
Top-level guardYes (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):

PlatformPackageInstall
Ubuntu / AlmaLinux / RHELlook-lang-linux-1.0.0.zipsudo bash install.sh
AlmaLinux / RHEL (dnf)look-lang-1.0.0-1.el8.x86_64.rpmdnf install look-lang-…rpm
Plesk (panel)look-lang-plesk-1.0.0.zipplesk bin extension --install …
Windows + XAMPPlook-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_preforkmpm_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 PlatformBuild MethodOutput DirectoryCommand
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

DBDSN FormatNote
MySQL / MariaDBmysql://user:password@127.0.0.1/dbnameZero dependency, wire protocol
SQLitesqlite://./data.db or sqlite://:memory:File or in-memory
PostgreSQLpostgres://user:password@127.0.0.1/dbnameProtocol v3, MD5 auth
PostgreSQL (port)postgres://user:password@host:5432/dbnameSpecify the port

Common Installation Problems

ProblemPlatformSolution
500 — Options not allowed herePleskRemove Options +FollowSymLinks from .htaccess
Binary doesn't run (203/EXEC)Linuxchmod +x look-fcgi — scp strips the execute bit
Binary won't run on another distro (GLIBC_2.xx not found)LinuxUse 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 UbuntuUbuntua2dismod mpm_eventa2enmod mpm_prefork cgi
Session doesn't work / 401FastCGIsession::start() must be called inside a route callback, not at global scope
hello.lk → "Endpoint not found"FastCGIOld look-fcgi (no direct mode) — rebuild the binary
Rewrite doesn't work on XAMPPWindowsThe rules must be inside <Directory "C:/xampp/htdocs">, not at global scope
Can't connect to DBAllDB_HOST=127.0.0.1 (TCP, not socket); hot reload with systemctl restart look-fcgi
No logsAllLOG_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:

WorkloadWorker pool (default)FibersUse
Fast route, no external I/O (c=50)11,506 req/s, p95 6 ms9,724 req/s, p95 8 mspool (~15 % faster, better tail)
Fast route (c=200)9,210 req/s, p95 28 ms8,097 req/s, p95 31 mspool
Slow external API (0.5 s), 1 worker, 10 concurrent3.62 req/s13.19 req/sfibers — 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

ComponentFileDescription
ThreadPoolfcgi_main.cppN worker threads; each incoming TCP connection is assigned to a thread
ConnPoolweb_stdlib.cppPer-DSN connection pool — SQLite: 1, MySQL/PG: N connections
make_dispatch_copy()interpreter.cppSetup 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_mutexfcgi_main.cppShared 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 serverApache + mod_proxy_fcgiNone — direct TCP
WebSocket
SSE
timer::
HTTP/1.1Apache parses itlook-fcgi parses it
Hot reload✅ mtime-based✅ mtime-based
Warm start
LOOK codeUnchangedUnchanged

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 VarDefaultDescription
LOOK_RATE_LIMIT_RPM0 (disabled)Max requests per minute per IP. 0 = disabled.
LOOK_RATE_LIMIT_BURST= RPMInstantaneous burst tolerance per IP (for SPA / mobile-app parallel requests).
LOOK_RATE_LIMIT_GLOBAL_RPM0 (disabled)Per-minute limit across all IPs — botnet protection. 0 = disabled.
LOOK_RATE_LIMIT_GLOBAL_BURST= GLOBAL_RPMGlobal 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

RuleDescription
parallel() outputIgnored — get results only through a channel
HTTP contextDon't call request::, response::, session:: inside a task — those values don't belong to the current request
DB connectionCan be passed with use ($conn) — takes a separate slot from ConnPool
receive() nullMeans the channel is closed + empty — break out of the loop
Panic handlingTask 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.

FunctionDescriptionReturns
parallel::active()Number of tasks currently runningint
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

ComponentFileDescription
Bytecode definitionsbytecode.hOpcode enum, Instruction struct, FunctionProto, the Value::BYTECODE_FN type
Compilercompiler.cpp / compiler.hAST → FunctionProto; RegisterAllocator, alloc_seq(n) contiguous-block allocation
VMvm.cpp / vm.hRegister-based, switch dispatch, CallFrame stack, TryCatchEntry, PARALLEL_CALL thread detach

Important Bug Fixes (Phase 16.5)

BugImpactFix
String-interpolation infinite loop100% CPU on the /product/{id} route patternTrigger condition narrowed to {$ — uses find("{$", i)
CALL_BUILTIN register contiguityThe second CALL_BUILTIN's args were in reverse orderalloc_seq(n) — bypass the free pool, contiguous block, locals_end_ update
MAKE_CLOSURE capture-hint orderingThe closure was created without captures, "Capture index out of range" errorMOVE/LOAD_GLOBAL preloads are emitted BEFORE MAKE_CLOSURE
Assoc-array foreach double-freeIt entered the LOCAL register pool twice, overwriting the valueif (next_ > locals_end_) locals_end_ = next_ in alloc_seq
db::connect VM setup"Not callable" errorAdded BUILTIN_NAMES[56]="db::connect"
channel() VM supportLOAD_GLOBAL "channel" → None → crashAdded BUILTIN_NAMES[57]="channel"

Benchmark — Docker Ubuntu, 8 workers

EndpointDescriptionInterpreterVMDifference
/heavy10,000-iteration arithmetic182 RPS1,427 RPS7.8x ↑
/light100-iteration string concat8,829 RPS15,653 RPS1.77x ↑
/routerNo DB, pure routing8,715 RPS8,521 RPS~1x (HTTP I/O bound)
/string500-iteration large concat3,467 RPS2,697 RPS0.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

RouteTypeDescription
/chatWSWebSocket chat — broadcast, hub channel, 30s keepalive ping
/statusGETJSON health — connected client count, uptime, memory
/monitorGETHTML stress-test dashboard
/monitor/eventsSSEReal-time stream of test results
/monitor/resultsGETAll test results as JSON
/monitor/run/{id}POSTStart 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)

TestGoalResultDate
vm-stressVM vs interpreter compute speedup✅ 7.8x /heavy compute (ms=8000)Jun 16
concurrent-500500/500 WS bots at once✅ 500/500 PASSJun 16
crash-recoveryAutomatic recovery after restart✅ PASSJun 16
parallel-wsWS send from inside parallel()✅ PASSJun 16
vm-fallbackLOOK_BYTECODE=0 fallback works✅ PASSJun 16
hot-reloadService reloads when chat.lk changes✅ PASSJun 16
unicode-xssUnicode and XSS payloads pass through safely✅ PASSJun 16
session-vmSessions work correctly in VM mode✅ PASSJun 16
concurrent-10005000 req, c=1000, 0 fail✅ 11,008 RPS, 0 fail (ab, localhost)Jun 16
ws-10001000 concurrent WebSocket connections✅ 1000 connections established, broadcast worksJun 16
memory-1h (Phase 17)No short-term memory leak✅ glibc high-water mark; +0.5MB/200 connections, real leak zeroJun 16
memory-72h72-hour RSS monitoring, 100MB delta threshold🔄 Ongoing — start=97MB, ends Jun 19, 2026Jun 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

StateRSSComment
Start~8 MBLOOK runtime
200 connections~8.5 MB+0.5 MB
4000 connections (cumulative)~200 MBglibc high-water mark — not returned to the OS
Real leakZerovalgrind / 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

LayerModelDeveloper Effort
Runtime (look-fcgi)N worker threads — each HTTP request in an isolated interpreter copyZero — the runtime handles it
Language (parallel + channel)LOOK tasks — parallel work within a single requestMinimal — channel design is enough

When to Use What?

ScenarioSolution
Different users' requests should run in parallelAutomatic in the runtime — --workers N is enough, no code changes
3 DB queries in a single request should run in parallelparallel() + 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 immediatelyparallel() + get the result via a channel

LOOK Concurrency Model

LayerHow it works
RuntimeThread pool (look-fcgi) — --workers N, per-request isolated state
Language levelparallel() + channel() — optional, only when needed
Web codeConcurrency isn't required — synchronous code runs at full speed too

Thread Safety

ProblemSolution
Two threads modify the same interpreter statemake_dispatch_copy() — each request in its own copy
Two threads use the same DB connectionConnPool — each thread borrows a separate connection
A request arrives during hot reloadshared_mutex — reload exclusive, dispatch shared
WS broadcast deadlockThe client list is copied, the lock released, then sent
parallel() task panicLogged 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

RequestBehavior
/test.lk, /api.lklk-cgi.exe runs the file, the output goes back to the browser
/, /aboutThe rewrite rule routes to index.lk (the router)
*.html, *.css, *.js, imagesApache 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:

EndpointCGIFastCGI WarmDifference
GET / (DB query)82 RPS418 RPS5x
GET /admin/me (no DB)81 RPS2150 RPS26x
GET /menu/... (heavy query)70 RPS234 RPS3.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:

ModeWorkersRPSRSSDuration / RequestsErrors
fcgi (Apache proxy)8~8,000648 KBcrashed after 90s
fcgi (Apache proxy)327,8468 MB10 min / 4.7M0
http interpreter (Apache bypass)322,4905,000 requests0
http interpreter (Apache bypass, DB)321825,000 requests0

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:

EndpointDescriptionInterpreterVMDifference
/heavy10,000-iteration arithmetic loop182 RPS1,427 RPS7.8x ↑
/light100-iteration string concat8,829 RPS15,653 RPS1.77x ↑
/routerPure routing (no DB)8,715 RPS8,521 RPS~1x
/string500-iteration large string concat3,467 RPS2,697 RPS0.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:

ConcurrencyTotal requestsRPSErrorsRSS
c=1,00050,0007,902029 MB
c=5,000100,0007,566029 MB
c=10,000200,0007,593028 MB
c=20,000200,0007,432028 MB
1M endurance1,000,0007,731028 MB
Blocking, c=20020,00010,558028 MB

Findings:

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:

TestScopeResult
AddressSanitizer + UBSan fuzzing16,000+ iterations of random/malformed HTTP (including chunked)0 crashes · 0 UB · 0 memory errors
ThreadSanitizerConcurrent-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 + fiber0 data leakage
Memory leakRSS monitoring over 1.75M requests28 MB steady — no leak
Regression suiteAutomated tests for 9 past production bugs9/9 passing
Manual security review4 rounds of line-by-line review: protocol parsers, VM arithmetic, network path17 hardening points closed
IMAP server fuzzing (ASan+UBSan)Malformed-input battery: overflowing/negative literals, broken seq-set, SEARCH tokenizer, protocol violation, path traversal0 UB · 0 crashes
Mail chain end-to-end (interop)SMTP delivery → per-user Maildir → IMAP LOGIN+SELECT+FETCH reads the same messagePassed — SMTP↔IMAP aligned

Vulnerabilities closed:

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

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

TestResult
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

LOOK — index.lk
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

TopicHow LOOK does it
DB connectionWarm start — opened once, stays alive for the life of the process
HTTP variablesrequest:: session:: namespaces — no hidden globals
Routingroute("GET", "/path/{id}", fn) built into the language
SQL injectiondb::query($conn, sql, [params])? auto-parameterized
Password hashauth::hash / auth::verify (PBKDF2-SHA256) in core
WebSocket / SSEroute("WS",...) — no separate setup
Concurrencyparallel() + channel() inside the language
PerformanceBytecode 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

FeatureNote
Basic types (int, float, string, bool, null)
Numeric array & associative arraySeparate concepts — no mixing
String interpolationFull expressions supported: {$price * 1.18}
Backtick raw stringMulti-line, no escaping
Null coalescing ??Chaining supported
Ternary ?:Nested chaining supported
switchGo-style — no break, multiple case, switch(true) pattern
All operators+ - * / % ** . <=> && || & | ^ ~ << >>
Compound assignment, ++ --
if / elseif / else
while / for / foreachincluding break / continue
Functions (named, anonymous, variadic, recursive)
Closure explicit captureuse ($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:: engineLayout inheritance, partial, {#if}, {#each}, {#block}
route() — routing built into the languageURL parameters, 404 handler
request:: / response:: / json::HTTP primitives
session:: / cookie::
db:: — MySQLZero dependency, parameterized queries
db:: — SQLitesqlite://./data.db or sqlite://:memory:
db:: — PostgreSQLWire protocol v3, MD5 auth, zero dependency
Automatic type conversionINT → int, DECIMAL → float — no manual conversion needed
FastCGI warm startPersistent DB, hot reload
Linux / Ubuntu 24.04 supportGCC build — CLI, CGI, FastCGI
AlmaLinux 8 / Plesk supportDocker build + Apache test — FastCGI + CGI fully working
Plesk VPS deploymentAlmaLinux 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 groupingDefault values, nested structs, arrays, $obj.field read/write, clean json::encode output
const iota — constant groupsGo-style chain, expression iota, top-level const global scope
Concurrent runtime — --workers NThreadPool + per-DSN ConnPool + make_dispatch_copy() — your LOOK code doesn't change
init_core_modules — log:: file:: date:: without useAccessible in the warm-start setup phase without use
--mode http — Event Loopepoll (Linux) / IOCP (Windows) — Apache bypass, its own HTTP/1.1 server, unlimited connections
parallel() — taskLOOK task; make_dispatch_copy() + std::thread::detach; panic → log::error
channel() — channelsend / 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:: modulews::send / on / close / broadcast / clients--mode http only
SSE — route("SSE",...)Server-Sent Events; SseConnection + SseRegistry; Accept: text/event-stream detection
sse:: modulesse::send / on / close / clients — named events, disconnect detection, --mode http only
timer:: moduletimer::after / every / cancel — global TimerManager, background thread, lock-free callback
--mode http bug fixEPOLLOUT edge-triggered bug fixed (level-triggered); SIGPIPE ignore added — the connection was dropping after the first request
Bytecode VMRegister-based VM; compiler.cpp (AST→FunctionProto); vm.cpp (switch dispatch); 30/30 tests PASS; 7.8x compute speedup; LOOK_BYTECODE=0 fallback
timer::cancel fixCancellation is now instant — erase(id) + cv_.notify_one(); the old behavior waited 30s
WebSocket Chat live500/500 bots PASS, 0 memory leak, look-chat service on port 9001
VM CALL goto fixThe run() outer while loop wasn't restarting after push_back → named function bodies never ran; fixed with goto call_dispatch
PrintStatement reg0 clobber fixemit(CALL_BUILTIN,0,print,r) was overwriting reg0; param $name became null; fixed with emit(CALL_BUILTIN,r,print,r)
PARALLEL_CALL SEGV fixSharedState.builtins raw ptr → local req_builtins dangling pointer; isolated with a deep-copy builtins_copy in the goroutine lambda
concurrent-1000ab -n 5000 -c 1000 localhost:9001 — 11,008 RPS, 0 failed requests, max 508ms
ws-1000websocat v1.13.0 musl — 1000 concurrent WebSocket connections, all established, broadcast active
Security review — SessionSecure+SameSite=Lax flags; /dev/urandom cryptographic RNG; destroy()+start() session-fixation protection; cookies_in.erase fix
Security review — XSS / Path Traversalprocess_content() → html::escape; use "file.lk" → weakly_canonical + main_script_ root confinement
Test infrastructure — 22/24 PASSStability / Performance / Security / Platform — see LOOK_TEST_RESULTS.md
VS Code Extension v0.1.0look-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 checkinglk --check (parse-only, without running) catches syntax + undefined function calls; Run/Serve/REPL commands + shortcuts; auto-detects lk
look test — test runnerlook test [pattern] [--verbose]; 10 built-in asserts; before_each/after_each; isolated dispatch copy; 29/29 PASS
look repl — interactive shelllinenoise embed; auto-print => val; multi-line blocks; stdlib auto-loaded; :help/:vars/:clear/:exit
cache:: moduleset/get/has/delete/flush/size/keys; TTL; CacheStore singleton — shared by all workers; 12/12 tests PASS
queue:: moduleNamed FIFO queues; push/pop/peek/size/clear/names; persistent cross-request; 9/9 tests PASS
jobs:: moduleSQLite durable job queue; pending→processing→done/failed; retry; delayed; worker handler; run/recover; 23/23 tests PASS
look install — package managerDownload 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

FeatureWhy
OOP (class / interface / trait)Coming as a use orm; extension in Phase 13
enum keywordconst + iota is enough — Go made the same decision
Mixed arraysData-structure clarity — numeric and associative kept separate
Global HTTP variablesExplicit access via request:: is preferred
Type declarationsWill remain a dynamically typed scripting language
match expressionswitch 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.

TopicDecision
db::pool() APIFastCGI 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 variablesAlways access via request:: — where it comes from must be clear
Mixed arraysNumeric and associative arrays stay separate — clarity over "take everything"
OOP in the language coreDoesn't enter the language core. Planned later as a use orm; extension
Template engine in coreComing as a use template; extension
Class-based routerroute() 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

StepDetail
DownloadGitHub API zipball endpoint — api.github.com/repos/{user}/{repo}/zipball/{ref}
HTTPSThe existing http_client (Schannel / OpenSSL) — zero extra dependency
RedirectThe GitHub 302 → codeload.github.com chain is followed automatically
ZIP extractionminiz amalgamation (322 KB, included) — the GitHub prefix (repo-sha/) is stripped automatically
SHA resolutionapi.github.com/repos/{user}/{repo}/commits/{ref} — full 40-character commit hash
look.lockSorted, deterministic "user/repo" = "ref@sha" format

Official Codlook Repositories

The package and module repositories managed by Codlook are on GitHub:

RepositoryContentsInstall
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+)

FeatureVersion
packages.codlook.com registryv1.1
Semver resolution (^1.2, ~1.0)v1.1
look publishv1.1
Dependency tree (transitive deps)v1.1
Native C++ extension APIv1.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

FunctionDescription
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

FeatureDetail
LocationEmbedded in look.exe — no separate binary
IsolationEach test gets a separate VM context via make_dispatch_copy()
before_eachRuns before each test (db::begin pattern)
after_eachRuns after each test, even on error (db::rollback pattern)
File discoveryThe tests/ folder, recursive .lk scan
Patternlook test authtests/test_auth.lk or files with "auth" in the name
Exit code0 = 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

CommandDescription
:helpCommand list
:varsList defined variables
:clearClear the screen
:exitQuit (or Ctrl+C / Ctrl+D)

Architecture

FeatureDetail
Line editinglinenoise embed — zero external dependency
History200 lines, arrow-key navigation, saved to a .look_history file
Persistent stateVariables and functions live for the whole session
AST ownershipowned_programs — closures hold pointers to the AST, alive until the REPL exits
Multi-lineThe count of open { is tracked, ... prompt until it closes
Error → continueA 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

FunctionDescription
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

FeatureDetail
Storagestd::unordered_map<string, CacheEntry> — global singleton
Thread safetystd::mutex — all operations atomic
TTLstd::chrono::steady_clock — lazy eviction (checked on get/has/size)
Warm startLives for the FastCGI process — NOT affected by hot reload
SharingAll 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

FunctionDescription
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

Featurequeue::jobs::
StorageRAM (std::deque)SQLite (disk)
Persistence❌ Reset when the process exits✅ Survives restart
Speed✅ MicrosecondsMilliseconds (SQLite I/O)
Retry✅ Automatic (max_retries)
Delayed jobs✅ delay_sec parameter
UseTransient data, cross-request bufferEmail, 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

FunctionDescription
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:

FieldTypeDescription
idintUnique job ID
queuestringQueue name
payloadstringJob data (usually via json::encode())
retry_countintHow many times it has been attempted so far
max_retriesintMaximum number of attempts
run_afterintUnix timestamp — not processed before this time
created_atintCreation time (Unix timestamp)
updated_atintLast update time

Architecture

FeatureDetail
StorageSQLite (jobs.db) — customizable via the JOBS_DB env
PersistenceJobs aren't lost even if the app restarts
Thread safetystd::mutex + SQLite autocommit — safe for concurrent workers
Retryfailed once max_retries is exceeded — manual retry with jobs::retry()
Delayed jobsrun_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
DependenciesZero — 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
FunctionDescription
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

CommandFunction
CAPABILITY · NOOP · LOGOUTIMAP4rev1 IDLE (+ STARTTLS LOGINDISABLED before TLS)
STARTTLSUpgrades a plaintext connection to TLS (RFC 3501 §6.2.1)
LOGINmail_users PBKDF2 verification (or dev single-user)
SELECT · EXAMINE · STATUS · LIST · LSUBSelect/examine mailbox; Maildir++ subfolders
FETCHseq-set · FLAGS UID RFC822.SIZE BODY[] BODY[HEADER] BODY[TEXT]
STORE · EXPUNGEChange flags (read/deleted), purge \Deleted
APPENDUpload a message from the client to the mailbox (literal + flags)
SEARCHflag · FROM/TO/CC/SUBJECT · BODY/TEXT · HEADER · seq-set — AND-ed
IDLELive new-mail push (RFC 2177) — no polling needed

Security

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

FunctionDescriptionReturns
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 processorsbool

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
ProviderMAIL_PROVIDER valueAuth methodSuccess code
MailgunmailgunHTTP Basic (api:key)200
SendGridsendgridBearer token202
PostmarkpostmarkX-Postmark-Server-Token header200

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)
Why async? SMTP/API latency varies between 100–500ms. Instead of making the user wait, respond to the request immediately and send the mail in the background. The 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 VarDescription
LOOK_SMTP_DKIM_DOMAINThe signed domain (must be the same domain as MAIL_FROM)
LOOK_SMTP_DKIM_SELECTORDNS selector — <selector>._domainkey.<domain> TXT record
LOOK_SMTP_DKIM_KEY_FILERSA 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 VarDefaultDescription
LOOK_SMTP_MAX_CONN1000Maximum concurrent connections
LOOK_SMTP_MAX_MSG_SIZE25 MBMaximum message size (bytes)
LOOK_SMTP_MAX_RCPT100Maximum recipients per message
LOOK_SMTP_MAX_ERRORS5Maximum errors per connection — dropped when exceeded
LOOK_SMTP_MAX_CONNS_IP10Maximum concurrent connections per IP
LOOK_SMTP_BANNERlocalhostHostname 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 VarDefaultDescription
LOOK_IMAP_PORTIMAP port (143) — upgraded via STARTTLS. IMAP is off if empty.
LOOK_IMAP_PORT_TLS0IMAPS / implicit TLS port (993) — opened only if a certificate exists
LOOK_IMAP_CERT / _KEYTLS certificate/key (PEM). If absent, LOOK_SMTP_CERT/KEY is shared.
LOOK_MAIL_USER_DBmysql://… — LOGIN from the mail_users table (falls back to LOOK_SMTP_USER_DB)
LOOK_MAIL_USER / _PASSSingle-user fallback if no DB (dev) — constant-time comparison
LOOK_MAIL_DIR/var/mail/lookMaildir root — INBOX: <dir>/<user>/inbox
LOOK_IMAP_MAX_LITERAL32 MBAPPEND literal cap — NO [TOOBIG] without reading if exceeded (OOM prevention)
LOOK_IMAP_MAX_CONN / _MAX_LINE / _MAX_ERRORS1000 / 8KB / 5Connection, line, error limits (DoS)
LOOK_IMAP_IDLE_TIMEOUT1800Idle-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.

PortStatusDescription
25❌ BlockedOutbound SMTP relay — no connection can be made from within LOOK
587✅ AllowedSTARTTLS + authentication — mail:: provider APIs use this port
465✅ AllowedSMTPS (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

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

  1. Applications → Add New Domain — pick a domain (script path and a free port fill in automatically), choose the mode, Add & Start.
  2. It shows Running in the list. Use Edit Code to edit index.lk in the browser → Save & Redeploy.

Workspace Features

FeatureDescription
DashboardLive CPU / Memory / Disk / Uptime — with mini charts (sampled every 4s)
Edit CodeIn-browser index.lk editor → Save & Redeploy (write file + restart service)
MonitorLive per-domain: status, CPU%, memory (RSS), PID, active connections, port, restarts, uptime + log tail
View Logsjournalctl -u look-<domain> — colored + copy to clipboard
Configure / Restart / Stop / RemoveActions menu — systemd service management

Architecture & Modes

Each domain is a systemd service (look-<domain>): nginx :443 → Apache :7081 (ProxyPass) → lk-fcgi :<port>.

ModeDescriptionUse
fcgiFastCGI — Apache mod_proxy_fcgiProduction recommendation, standard REST/web
httpHTTP — lk-fcgi --mode httpDirect HTTP port, reverse proxy

Notes

TopicDescription
One-time sudo grantPlesk 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.
StateThe domain list is at /usr/local/psa/var/modules/look-lang/domains.json (panel-writable)
UpdatesReplaced 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

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.batRun 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

ErrorCauseSolution
500 Internal Server Errorlk-cgi.exe didn't runCheck the last lines of C:\xampp\apache\logs\error.log
Source code shows in the browserThe CGI handler wasn't loadedRun install.bat as administrator again
404 Not FoundThe httpd.conf Directory block is missingRun install.ps1 directly
Windows Defender warningUnsigned binaryAllow → run again
lk-cgi.exe not found errorNot builtDo Step 1

Roadmap

PhaseContentsStatus
0–5Foundation, Core Language, Stdlib, CGI, Web, DB✅ Done
6QR Menu — 29 routes, full CRUD, session auth✅ Done
7Stress test — zero language errors✅ Done
8FastCGI warm start — 5x performance, persistent DB✅ Done
8.1Ternary ?:, array::find/any/all✅ Done
8.2MySQL automatic type conversion✅ Done
8.5SQLite — DbConnection interface, zero dependency✅ Done
9Linux port — GCC build, CLI, CGI, FastCGI, MySQL+SQLite✅ Done
9.1switch — Go-style, no break, multiple case, default✅ Done
9.6Ubuntu + Plesk/Apache deployment documentation✅ Done
9.8Error system — SourceLocation, stack trace, LookRuntimeError/ParseError✅ Done
10.1Upload — request::file(), file:: module, magic byte, SHA-256✅ Done
10.0date:: module — now, format, parse, add, sub, diff, weekday✅ Done
9.5PostgreSQL wire protocol v3 — MD5 auth, zero dependency✅ Done
Bug Fixstring comparison (< > <= >=), string::pad_left/pad_right, array::push/pop in the module✅ Done
PleskPlesk VPS deployment (AlmaLinux 8.10, live); session fix; direct mode✅ Done
11struct (Go-style, default values, nested, clean json) + const iota (Go-style chain, member access)✅ Done
12Concurrent runtime — ThreadPool + ConnPool + make_dispatch_copy(), --workers N✅ Done
13--mode http — Event Loop (epoll Linux / select Windows), HttpServer, hot reload, Apache bypass✅ Done
14parallel() + channel() — LOOK task + channel; fan-out, pipeline; 9/9 tests✅ Done
15WebSocket — route("WS",...), ws:: module, RFC 6455 frame codec, SHA-1+Base64, WsRegistry broadcast-safe✅ Done
16SSE — 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.5Bytecode 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
17Chat live — WS + broadcast + timer:: keepalive; 500/500 bots PASS; timer::cancel fix (instant erase+notify); 0 memory leak verified✅ Done
17.1VM 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
18Blog live — template::, CRUD, auth, file upload✅ Done
18.5use "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
19Security (session Secure+SameSite, /dev/urandom RNG, fixation, XSS, path traversal) + 22/24 tests PASS + VS Code Extension v0.1.0✅ Done
19.1Stdlib expansion: string::format, string::regex_match/replace/match_all, math::max/min variadic+array, array::flatten/chunk/zip✅ Done
19.2http:: client — GET/POST/POST_JSON/PUT/PATCH/DELETE; OpenSSL (Linux) + Schannel (Windows); 10/10 PASS✅ Done
19.3look.toml package-system architecture design + Native Extension API v1.1 draft + docs/package-system.md✅ Done
Sprint 3look test runner — look test, 10 assert functions, before_each/after_each, 29/29 PASS✅ Done
Sprint 4REPL — look repl, linenoise embed, auto print, multi-line blocks, :help/:vars/:clear/:exit, AST-ownership fix✅ Done
Sprint 5cache:: module — in-memory + TTL, thread-safe warm-start cache; 12/12 tests PASS✅ Done
Sprint 6queue:: module — named FIFO, persistent cross-request, 9/9 PASS✅ Done
Sprint 7jobs:: module — SQLite durable queue, retry, delayed jobs, dead-letter, worker/run; 23/23 PASS✅ Done
Sprint 8look install — download a GitHub zipball, extract to pkg/, look.lock SHA; zero dependency (miniz)✅ Done
v1.0-prelook version/--version; jobs::recover() crash recovery; mail:: (Mailgun/SendGrid/Postmark); PostgreSQL BOOL fix; install.sh (Ubuntu+AlmaLinux, systemd+nginx automatic)✅ Done
v1.0Plesk 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.1Windows IOCP — AcceptEx + WSARecv + WSASend + PostQueuedCompletionStatus; c=500 844 RPS 0 errors; SelectEventLoop (64-connection limit) → unlimited connections✅ Done
v1.x SprintDKIM 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 workspacePlesk 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

GoalContentsStatus
Load-profile publicationThroughput/latency tables measured across different workloads⏳ Planned
Docker Hubdocker run codlook/look — 5-second install, no build✓ Live
Look DeployManaged 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

#TestToolStatusDate
1Smoke Test (10 min)curl✅ DoneJun 9, 2026
272h Long Run Stressstress-runner.sh✅ DoneJun 10–13, 2026
3Memory Leak Monitoringleak-monitor.sh✅ DoneJun 10–13, 2026
4MySQL Recoverymysql-recovery-test.sh✅ DoneJun 10, 2026
5Concurrency — up to 1000run-tests-v2.sh✅ DoneJun 13, 2026
6Connection Leakrun-tests-v2.sh✅ DoneJun 13, 2026
7Crash Recoveryrun-tests-v2.sh✅ DoneJun 13, 2026
8MySQL Recovery v2 (db_check fix)run-tests-v2.sh✅ DoneJun 13, 2026
9Hot Reload (under load)run-tests-v2.sh✅ DoneJun 13, 2026
10Large Payload (100KB / 1MB / 10MB)run-tests-v2.sh✅ DoneJun 13, 2026
11Edge Case (boundary values)run-tests-v2.sh✅ DoneJun 13, 2026
12Unicode / Encoding / XSSrun-tests-v2.sh✅ DoneJun 13, 2026
13Session Persistencerun-tests-v2.sh✅ DoneJun 13, 2026
14Fuzz TestPython fuzzer (custom script)✅ DoneJun 2026
15Penetration (Pen Test)curl / manual — Phase 19✅ DoneJun 2026
16Low-Resource VPS (1CPU / 1GB RAM)Docker✅ DoneJun 2026
17vm-stress (VM compute speedup)look-chat /monitor/run✅ DoneJun 16, 2026
18concurrent-1000 (5000 req, c=1000)ab (ApacheBench)✅ DoneJun 16, 2026
19ws-1000 (1000 concurrent WebSocket)websocat v1.13.0✅ DoneJun 16, 2026
20memory-72h (72-hour RSS monitoring — chat/WS)nohup bash + /proc/PID/status✅ DoneJun 16–19, 2026
21Load ladder c=1k/5k/10k/20kab + kernel tuning✅ 0 errorsJul 2026
221,000,000-request endurance (c=10k)ab✅ 0 errors · RSS 28MB steadyJul 2026
23ASan + UBSan HTTP fuzzing (16k+ iter)fuzz_http.sh✅ 0 UB · 0 crashesJul 2026
24ThreadSanitizer data-race scantsan_run.sh✅ 0 races (1 found→fix)Jul 2026
25Cross-contamination (20k, blocking+fiber)integrity_test.sh✅ 0 data leakageJul 2026
26DoS / smuggling protectionsraw HTTP + regression✅ 413/400 correctJul 2026
27Chunked Transfer-Encoding (RFC 7230)raw HTTP + ASan fuzz✅ Works · 0 UBJul 2026
28Regression suite (9 production bugs)run_regression.sh✅ 9/9Jul 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.

AreaFinding / ActionVerification
Memory safetyAll HTTP/DB parsers fuzzed under ASan+UBSan16k+ iter, 0 UB
Data racedate::now tz race found → localtime_r + tzset()TSan 1→0 race
Data leakagePer-fiber/thread connection isolation verified20k requests, 0 contamination
Memory leakConnection-pool double-release structurally prevented1.75M requests, RSS steady
DoS — bodyLOOK_MAX_BODY_SIZE (10MB) → 413Verified live
Request smugglingCL+TE together → 400 (RFC 7230)Verified live
HTTP parserMalformed CL → 400, full chunked TE support5k iter ASan, 0 UB
CI automationASan/UBSan fuzz + TSan on every push.github/workflows/security.yml

2 — 72-Hour Stability Test

MetricResultComment
Total HTTP requests766,032~78 hours uninterrupted
Application-caused errors0Zero crashes, zero exceptions
Average response time136 msUnchanged from start to finish
Min / Max response30 ms / 45 sMax = a momentary VPS network spike
RPS (stability test)3.50Sequential curl — a stability measurement, not a load test
RPS (warm start, with DB)418FastCGI warm start
RPS (warm start, no DB)2,15026× vs CGI mode
Memory (RSS) — start17 MB
Memory (RSS) — after 66 hours74 MBPlateaued, growth stopped
MySQL connection threads3–6Stable over 78 hours
look-fcgi PIDUnchangedNot a single restart, not a single crash

Network Anomalies (out of 766k requests)

CodeCountActual Cause
000~20VPS/nginx momentary timeout — look-fcgi unaffected (PID proof)
50010The MySQL recovery test's deliberate stop step
5023Apache layer transient — look-fcgi unaffected
Application error0

3 — Memory Leak Monitoring (66 Hours)

TimeRSSVSZComment
Start~17 MB~50 MB
Hour 24~65 MB~80 MB
Hour 48~66 MB~81 MBPlateau began
Hour 6674 MB90 MBPlateau — 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)

StepScenarioExpectedActual
1Normal operation200 OK200 ✅
2MySQL stopped503 graceful500 ⚠️
3MySQL restart, no hot reload503200 ✅ auto reconnect
4touch index.lk → hot reload200 OK200 ✅

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)

ConcurrentTotal TimeAvg ResponseErrors
5011,882 ms237 ms0
10022,459 ms224 ms0
20044,278 ms221 ms0
500109,379 ms218 ms0
1000218,876 ms218 ms0

Route with DB (50,000-row products table)

ConcurrentTotal TimeAvg ResponseErrors
5020,029 ms400 ms0
10040,122 ms401 ms0
20079,970 ms399 ms0
500200,635 ms401 ms0

Mixed — 3 different endpoints at once

ScenarioTotal RequestsAvg ResponseErrors
/ + /menu + /search in parallel300208 ms0

Even at 1000 concurrent users the average response stayed at 218ms — performance didn't degrade as load rose.

6 — Connection Leak

MeasurementValue
Before 500 parallel requests8 connections
Immediately after 500 parallel requests8 connections
After a 5-second wait8 connections
LeakZero

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

MetricValue
PID before / after17999 → 33492
Status while stopped503 ✅
After restart200 ✅
Recovery time< 3 seconds

8 — MySQL Recovery v2 — db_check Fix Verification

StepScenarioExpectedActual
1Normal operation200200 ✅
2MySQL stopped503503 ✅ FIX WORKED
3MySQL restart, no hot reload200 (auto reconnect)200 ✅
4touch index.lk → hot reload200200 ✅

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)

MetricValue
touch index.lk during 200 parallel requestsPID unchanged ✅
Status after reload200 ✅
Dropped requestsZero

10–12 — Payload, Edge Case, Unicode / XSS

CategoryScenarioResult
Large Payload100 KB JSON body404 — no crash ✅
Large Payload1 MB JSON body404 — no crash ✅
Large Payload10 MB JSON body404 — no crash ✅
Edge CaseID=0, ID=-1, ID=99,999,999404 graceful ✅
Edge CaseEmpty query parameter403 — validator ✅
Edge CasePath traversal (../../../../etc/passwd)404 — router blocked ✅
Edge CaseSQL injection path (1'--)404 — route didn't match ✅
Edge CaseDELETE method404 — undefined ✅
UnicodeTurkish characters (köfte)200 ✅
UnicodeEmoji (🍕pizza)200 ✅
XSS<script>alert(1)</script>403 — blocked ✅
SQLi encoded%27;SELECT * FROM admins--403 — blocked ✅

13 — Session Persistence

StepScenarioResult
1Login (admin@qrmenu.local)200 ✅ cookie received
2Auth route with cookie200 ✅
3Same cookie after look-fcgi restart200 ℹ️ (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

EndpointDescriptionInterpreterVMDifference
/heavy10,000-iteration arithmetic loop~182 RPS~1,427 RPS7.8x ↑
/light100-iteration string concat~8,829 RPS~15,653 RPS1.77x ↑
/routerPure 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

MetricValue
Total requests5,000
Concurrent connections1,000
RPS11,008
Failed requests0
Max response time508 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

MetricValue
Concurrent WS connections1,000
Failed connections0
Server statusActive — ws::clients() worked
broadcast testWorks
Server crashNone

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

MetricValue
Starting RSS97 MB (look-fcgi-chat PID=79377)
Sampling interval30 minutes (144 samples)
Failure threshold100 MB delta
Status✅ Done
End dateJune 19, 2026 ~02:31
ResultZero 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)

TargetRoundsCrashesHangsResult
Parser / Interpreter (CLI)10,00000✅ PASS
HTTP live endpoint00✅ 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

CategoryTestResult
SQL Injection1'; DROP TABLE products; --✅ Blocked — parameterized query
XSS<script>alert(1)</script>✅ 403 / html::escape
Path Traversal../../../../etc/passwd✅ weakly_canonical() + root confinement
Session FixationSame SID before and after login✅ SID renewed via destroy()+start()
Auth BypassUnauthorized access to an admin route✅ 401 — session check
Cookie SecuritySet-Cookie header inspection✅ Secure; SameSite=Lax added
RNG QualitySession 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

MetricValue
CPU1 vCPU (--cpus=1)
RAM1 GB (--memory=1g)
look-fcgi workers2 (minimum)
look-fcgi RSSStable — no OOM
Crash0
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!"
Why no framework? Routing is built into the language. A 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

DatabaseDSN
MySQL / MariaDBmysql://user:pass@host/db
SQLitesqlite://./database.db
SQLite in-memorysqlite://:memory:
PostgreSQLpostgres://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}
Explicit scope: 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

LayerWhat It Does
Magic-byte checkValidates the real type by looking at the file content, not the extension. Blocks attempts to hide a .php extension as .jpg.
MIME allow-listFiles whose type isn't in the allow_mime array are rejected.
Size limitmax_size in bytes — an exception is thrown if the limit is exceeded.
SHA-256 namingfile::store() renames the file with its SHA-256 hash. The original name doesn't remain on the system.
Web root protectionThe 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