Skip to content

Latest commit

 

History

History
2027 lines (1824 loc) · 124 KB

standard-library.md

File metadata and controls

2027 lines (1824 loc) · 124 KB

VelarScript Core Standard Library

Status: active clean-break design Compiler: current VelarScript compiler Runtime: existing JavaScript engine; no separate VM

Contract

The Core library combines the most useful everyday parts of Python and modern JavaScript behind a small explicit VelarScript surface. It is not a copy of either standard library.

velar/* is a closed vocabulary owned by the language. A module belongs here only when it provides universal computation that any program may need, or a minimal orthogonal capability primitive for interacting with the outside world. Domain functionality such as editor, game, or chart tooling is always an installable library, even when it is implemented entirely in VelarScript.

  • Core ownership, permanent vocabulary, and purity are separate contracts. The permanent namespaces provide familiar, widely useful operations; their names do not promise purity. Math.random observes nondeterminism, Promise.sleep schedules time, and the prelude print produces diagnostic output. Files, networks, processes, environment, and UI resources require an explicitly imported capability. Deterministic toolboxes that only some programs need also keep an import. Caching, reordering, or repeating a call requires proof from that operation's contract.
  • Everyday value operations live on checked string, number, and collection members. Nothing patches JavaScript prototypes or creates new global names.
  • Implementation language does not determine membership. Reusable domain modules written in VelarScript distribute an ordinary installable package with a mandatory root velar.entry and any needed exact velar.entries subpaths, then are imported by those package specifiers after installation. Official non-standard experiments use the dedicated public @velarscript-labs/* npm scope; @velarscript/* stays reserved for Standard owners, official targets, and the language toolchain.
  • Collection transforms return new lists and maps unless their name explicitly describes another result. JavaScript reference identity and number semantics remain unchanged.
  • Missing collection and parsing results use null, never JavaScript undefined.
  • The compiler preserves element, callback-result, map, optional, runtime data, and Promise result types through built-ins. User-defined generic functions, records, and classes use the same checked type relationships and call-site inference as the Core vocabulary.
  • Runtime validation is explicit. Argument mistakes that can be proven statically are diagnostics; dynamic misuse throws TypeError or RangeError.
  • Fixed official signatures expose the same name=value calls as user-defined functions. Their documented parameter names are part of the checked API and editor signature help; source expressions still evaluate left to right.
  • A positional overload or pure rest call has no invented keyword surface. randomInt, numeric min/max, and URL join therefore stay positional, matching the familiar operation they represent.
  • Every API that requires List<T> enforces the same dense, field-free, mutable data-element List boundary used by the language runtime. Sparse or frozen JavaScript arrays, arrays carrying hidden/extra fields, and accessor-backed elements do not become valid Lists through a library call; validation never invokes an element getter.
  • Map and Set boundaries use native internal-slot checks and prototype operations. Cross-realm native collections are accepted, while subclass overrides cannot replace size, iteration, membership, or lookup semantics. Collection brands and runtime Type traversal capture their Array/Map/Set, reflection, size, iterator-factory, and iterator-step operations when the generated module initializes, so later global or prototype replacement cannot change a checked is List/Set/Map/Record result. Direct List construction, indexing, iteration, snapshots, transforms, ordering, and mutation likewise use an initialization-captured List host ABI. Dense reflection and numeric bounds use explicit wrappers; value traversal is indexed and native join/sort/reverse operations are captured once. Set/Map/ Record operation hosts are separate runtime layers rather than aliases of this List contract. Set and Map construction and receiver operations also have their own captured host layer: native size, membership, lookup, mutation, keys/values/entries, iterator steps, copy, and clear are fixed when the generated module starts. Subclass overrides and later prototype or iterator replacement therefore cannot redirect ordinary Set/Map behavior. Record operations remain a separate data-object boundary. Record validation and receiver operations capture their own field discovery, descriptors, definition, deletion, identity, freezing, Reflect, and errors. Bracket access, one-slot/pair iteration, copy, mutation, and field snapshots use explicit indexed loops, independently of Array iterator or .map behavior. Record literal/spread and Record/List binding remain compiler lowering rather than receiver-method shortcuts, but reuse the same captured reflection, definition, allocation, and Error operations. Their generated part, field, and rest traversal is explicitly indexed, so later ambient replacement cannot redirect construction or destructuring.
  • Compiler-known runtime Type values use two adjacent owned runtimes. One immutable registry proves that a Type<T> value was compiler-created; the other captures WeakMap/Set recursion state, descriptor reads, Array/Promise/class identity, freezing, Reflect, and ValidationError behavior. Type.is and Type.parse therefore remain cycle-bounded, getter-free, and stable after ambient constructor/prototype or class Symbol.hasInstance replacement without conflating validation execution with registry identity.
  • Core conversion is deliberately asymmetric and small: str(value) performs explicit display conversion, while number(text) -> number? parses one complete finite decimal after trimming surrounding whitespace and answers null for everything else — a partial parse, a radix form, a digit separator, "Infinity", or an overflow. The charter's section 7 states the exact grammar. JavaScript Boolean, Number, and String globals are not source bindings, so truthiness, empty-string-to-zero, partial parsing, and NaN do not re-enter through ambient coercion.
  • Core Node builds copy only imported official modules beside the generated output. Portable modules also bundle and tree-shake in Web builds. Local platform modules (velar/serve, velar/fs, velar/env, velar/host, velar/terminal, velar/path, velar/process) are compile-time rejected for Web targets as VEL6008, positioned on the import, with platform-specific guidance: each names the Web module that does that job (velar/http and the dev server, velar/files, velar/config, velar/url) or says plainly that the Web has no equivalent.
  • Resource-producing APIs are bounded contracts, not best-effort host calls. A List contains at most 1,000,000 items; text and encoded JSON are limited to 16 MiB; JSON data contains at most 1,000,000 values and 128 nested collections. List spread, Set/Map construction, and collection mutation preserve the 1,000,000-item invariant; an update to an existing Set value or Map key remains valid at the ceiling, while growth fails with RangeError. Dynamic misuse fails before a native capability is invoked.

Three groups, and how to tell which one a module is in

The spelling of an operation, its package owner, and its effects are separate parts of its contract. Permanent vocabulary is immediately in scope; named modules are chosen explicitly; external-resource capabilities declare their host boundary. An import does not itself mean an operation is effectful, and a permanent namespace does not itself mean an operation is pure.

Group 1 — permanent namespaces (no import)

Json., Promise., Math., and Text., alongside the prelude names print, str, number, equals, and range.

This set is short and closed because it carries familiar, broadly useful computation and target-neutral runtime control. Purity belongs to individual operations: Math.random, Promise.sleep, and print expose nondeterminism, scheduling, and diagnostic output respectively. A new permanent name must justify its universal responsibility and discoverability; it cannot silently introduce access to external resources.

Both spellings that reach a permanent member are retired — the named import and the namespace import — and each earns a diagnostic that teaches the namespace spelling and a velar fix rewrite that performs it.

Group 2 — target-neutral toolboxes imported by name

velar/binary, velar/hash, velar/validation, velar/random, velar/task, velar/url, velar/test, and, on Web, velar/look.

These modules provide tools a program chooses deliberately. Each operation states its effects; deterministic computation, task scheduling, test execution, and visual declarations do not become one effect category just because they share the named-import spelling.

A computation already expressed by a collection operation belongs on the collection. Those operations are checked List members; range is a prelude. Clock reading, identifiers, and application logging remain explicit imports through velar/time, velar/id, and velar/log.

Group 3 — capabilities

velar/fs, velar/path, velar/process, velar/env, velar/host, velar/serve, velar/terminal, velar/http, velar/worker, velar/websocket, and the Web modules documented in web-api.md.

For a capability the import line is the audit signal — it is how a reader sees what a module touches — so no capability becomes permanent for convenience, however often it is used.

range (prelude, no import)

range(end), range(start, end), and range(start, end, step) produce a stop-exclusive, checked List<number> of at most 1,000,000 items; negative steps count down, and a zero or non-advancing step fails. The bounds are also named — range(start=1, end=4, step=2). Importing the name is an error that teaches the bare spelling.

The compiler recognizes only the direct one-slot loop head for value in range(...):: it evaluates the arguments once, performs the same complete range validation, and emits a native counter loop without materializing the List. Aliased, saved, nested, or two-slot iteration keeps the ordinary List contract, so there is no second public iterable type.

Every other collection operation is a checked List member — unique, compact, flatten, chunk, partition, groupBy, keyBy, countBy, zip, repeat, min(by=), max(by=), and sorted(by=, descending=) among them. Charter section 8 is their reference. zip answers List<Pair<T, U>>: Pair<A, B> is the Core record {first: A, second: B}, needs no import, and is written in an annotation exactly like List<T> is.

Binary data, deterministic computation, and work ownership

velar/binary is the common Node/Web binary boundary. Bytes is an immutable snapshot with size and read-only integer indexing. UInt8Buffer, UInt16Buffer, UInt32Buffer, and Float32Buffer are fixed-size mutable working memory for compact state and numeric datasets. They provide checked indexing, independent copy()/slice(start=0, end=size) values, values() -> List<number>, and toBytes; multi-byte buffers require an explicit ByteOrder. Matching *FromBytes functions restore them. Every value, size, slice bound, and index is checked, so the API never inherits typed-array truncation, non-finite floats, or out-of-bounds no-ops. Constructors and every runtime Type.is/Type.parse boundary enforce one 64 MiB byte ceiling before scanning or copying a typed array. The compiler emits direct specialized index operations rather than routing these types through reactive Lists or ordinary methods.

values() snapshots positions 0 through size - 1 into one fresh mutable List; later writes to either value do not reach the other. It is available on mutable and readonly fixed buffers and enforces the universal 1,000,000-item List ceiling before allocating. Bytes remains the immutable byte snapshot and does not gain this numeric-working-memory conversion.

UInt32Builder and Float32Builder grow only up to their required maxElements; push(value) rejects overflow and finish() returns one exact fixed buffer and seals the builder. Safe JavaScript declarations map Uint8Array and Node Buffer results to read-only Bytes, Uint16Array to UInt16Buffer, Uint32Array to UInt32Buffer, and Float32Array to Float32Buffer. A JavaScript Uint8Array parameter accepts either Bytes or UInt8Buffer; no Buffer-specific API enters source.

The constructors come in one shape per element width, and all of them are named imports from velar/binary. uint8Buffer(size), uint16Buffer(size), uint32Buffer(size) and float32Buffer(size) allocate a zeroed fixed buffer of that many elements. uint16FromBytes(snapshot, order), uint32FromBytes(snapshot, order) and float32FromBytes(snapshot, order) rebuild one from a Bytes snapshot under the given ByteOrder; uint8FromBytes(snapshot) takes no order, because a single byte has none to read. uint32Builder(maxElements) and float32Builder(maxElements) open the two growing builders above — there is no 8- or 16-bit builder, so a buffer of either width is allocated at its final size.

A standard type carries its members wherever it is handed to you. velar/fs's readBytes returns a Bytes; you can read .size and index it without importing velar/binary. Import the declaring module only when you need to write the type's name — in an annotation, a parameter type, or a Type validator.

import {ByteOrder, Bytes, uint16Buffer, uint16FromBytes} from "velar/binary"

const values = uint16Buffer(4096)
values[0] = 7
const snapshot: Bytes = values.toBytes(ByteOrder.little)
const restored = uint16FromBytes(snapshot, ByteOrder.little)
assert restored[0] == 7
const numbers: List<number> = restored.values()

velar/random creates a deterministic Random from a string or safe-integer seed. Its number(), int(start, end?), bool(probability=0.5), pick(values), shuffle(values), and fork(label) operations have identical Node/browser results. A fork derives an independent stream from the original seed and label; it does not consume or couple itself to the parent's current position. The seed is absorbed into the generator's full internal state, so distinct string or safe-integer seeds — and distinct fork labels — give distinct streams rather than colliding onto a smaller space. int(start, end?) accepts the same increasing safe-integer range as Math.randomInt. number() returns a value in [0, 1) carrying 32 bits of entropy; code that needs a full-mantissa double should compose two draws itself.

velar/task owns structured asynchronous work. task(work, parent?) passes a Cancellation into work, propagates a parent cancellation, and returns an owned Task<T>. cancel(reason?) requests cancellation and waits for the work to finish; CPU-heavy work cooperates with await cancellation.checkpoint(). A checkpoint reached after cancellation, and a send or next waiting on a channel when its cancellation fires, reject with CancellationError — a named export of velar/task, so if error is CancellationError: tells a cancelled call apart from a failed one. cancel() and close() wait for cleanup without re-raising that cancellation; result() still rejects with the task's error. withTimeout(task, duration) cancels the underlying task before it rejects with the Core built-in TimeoutError, which needs no import and is the same class Promise.timeout raises — one concept, one identity (charter section 11). The retired TaskTimeoutError spelling is refused at the import with that rewrite. A using Task cancels and joins automatically on every exit.

channel(MessageType, capacity=64) creates a typed, bounded FIFO Channel<MessageType> for many producers and one consumer. The Runtime Type is passed explicitly because capacity alone cannot determine MessageType, and every value is validated before entering the queue. send(value, cancellation?) waits while the buffer is full, but waiting senders are also bounded by capacity; exceeding that second bound rejects with ChannelBackpressureError. trySend(value) never waits and returns false when the buffer is full — that is the only answer it gives instead of sending. next(cancellation?) returns the next value or null after a closed channel has drained, and therefore also supports async for. Only one next call may wait at a time. close() is idempotent: buffered values remain readable and no new value is accepted. After it, every sender fails with ChannelClosedError — the senders already waiting, send, and trySend alike: a closed channel is not backpressure, so there is no false that would mean "try again later".

A channel is also how a caller parks until its own answer arrives, which is what request/response multiplexing over a single connection needs: one reader owns the connection's next() and forwards each answer into a capacity-1 channel the waiting call is already reading, so nothing polls. packages/desktop/README.md writes the pattern out over a ServiceConnection; it is the same over a WebSocketConnection or any other bounded pull source.

import {channel} from "velar/task"

type Message:
    kind: string
    value: string

const messages = channel(Message, capacity=64)
await messages.send({kind: "changed", value: "0,0,0"})
messages.close()

async for message in messages: print(message.value)

Workers and pull-based WebSockets

velar/worker uses entries declared in velar.json:

{
  "formatVersion": 2,
  "entry": "src/main.vel",
  "workers": {"processor": "src/data-worker.vel"}
}

worker(name, RequestType, ResponseType, capacity=64) starts one worker; workerPool(name, RequestType, ResponseType, size, capacity=64) starts a bounded pool. Both expose call(request, cancellation?, timeout?) and close(). A pool also exposes broadcast(request, cancellation?, timeout?), which preflights aggregate and per-member capacity, sends the request once to every live member, and returns replies in member-creation order. A worker entry calls serveWorker(RequestType, ResponseType, handler, capacity=64). Runtime Types validate both directions. Full-storage Bytes and fixed numeric buffers are found inside bounded, cycle-safe List/Map/record graphs. postMessage copies small caller-owned backing buffers directly; larger shared buffers use a transferable snapshot. The caller's values are never detached implicitly. Queue capacity supplies backpressure, and a crash rejects every pending call with one stable worker error identity. The four identities are named exports of velar/worker: WorkerBackpressureError when the queue — a worker's own, or a pool's aggregate — is already full, WorkerClosedError for a call on a closed worker or pool and for the calls close() rejects, WorkerCallError for a request the handler itself failed (the remote name, message, and stack travel with it), and WorkerCrashedError when the worker dies, sends an invalid response, fails to acknowledge a cancelled request within its grace period, or a pool has no live member left. A call's configured timeout rejects with Core TimeoutError. The implementation selects browser Worker or Node worker_threads; source never handles native URLs or ports.

velar/websocket exposes connect(url, options?) on Node and Web, plus Node listen(options). A WebSocketConnection sends string | Bytes, and its next() -> Promise<(string | Bytes)?> is consumed directly or with async for. closeInfo() -> Promise<WebSocketClose> returns the terminal numeric code and reason after the handshake completes. send resolves only after the bounded pending-byte budget drains. Connection, message, send, accept, unread-message count, and aggregate unread-byte limits fail explicitly, each as its own class (charter section 11): WebSocketBackpressureError when a budget is full, WebSocketProtocolError when the peer breaks the protocol or the handshake, WebSocketTimeoutError when a bounded wait expires, and WebSocketClosedError when the connection has already ended. Node additionally enforces process-wide connection, queued message, pending connection, pending send, and 128 MiB byte budgets, so empty messages cannot bypass accounting. maxQueuedBytes defaults to 16 MiB on Node and Web. listen may receive a ServeApp or the same typed low-level handler as velar/serve, so HTTP and upgrade traffic share one port and a composed app keeps one startup/shutdown lifecycle. A ServeApp may declare multiple @websocket routes; each route owns its checked pattern, pre-upgrade inputs, one WebSocketConnection, and complete handler lifetime. In that declarative mode WebSocketServer.next() and the listener's legacy single path option are unavailable. Its maxBodyBytes option bounds HTTP bodies on that shared listener. host is non-empty text of at most 255 code units without NUL. origins accepts exact canonical HTTP/HTTPS origins; by default upgrades carrying Origin receive 403 before admission, while non-browser requests without Origin remain allowed. ["*"] is the explicit unrestricted policy. Normal EOF preserves accepted messages until next() drains them. Protocol/limit failure and server stop release unread queues immediately; an abandoned connection is terminated and releases its reservations when its owner is collected. Connections and servers are owned resources for using.

Application libraries and adapters

Codecs, compression, procedural algorithms, database models, concrete drivers, and deployment-provider integrations are not part of the Standard API or this repository. The consuming project defines or installs the smallest checked VelarScript surface it needs and owns the implementation, dependency versions, resource ceilings, concurrency behavior, and tests.

Core has no database keyword, schema lowering, SQL parser, privileged adapter package name, or provider-specific deployment surface. Reuse across projects is handled through the public velar.entry package contract, never by expanding the reserved velar/* namespace or adding hidden CLI dependencies.

Text. (permanent, no import)

Common string operations are checked members: size, trim, upper, lower, slice, char, has, index, count, startsWith, endsWith, isBlank, split, replace, replaceAll, repeat, padStart, and padEnd. The compiler lowers them to bounded helpers rather than trusting JavaScript prototype methods. They support named arguments and first-class binding exactly like collection methods.

size, char(index), slice(start=0, end=size), and the positions returned by index(text, start=0) use Unicode code points, matching string iteration rather than JavaScript UTF-16 units. slice and index count negative positions from the end and clamp; char does not — its index domain is 0 ≤ index < size, so an index at or past the end reads as the absent null its string? result exists to report, while a negative index is out of range and throws IndexError, naming the index and the size. A non-integer position throws the same IndexError in every one of them: charter section 11 files an out-of-range or non-integer position under that one name, whether the position is a List's or a string's, and try never turns one into null. A count is not a position — repeat, padStart and padEnd reject a size outside 0 through 16777216 as a range failure. index also clamps its start and returns null when no match exists. text.has(part) and part in text are the method and operator forms of the same substring test. Direct string indexing stays absent.

The Text. namespace is the extension toolbox beside those core methods: it needs no import, and nothing moves between the two, so the member list a reader must hold in mind never grows. It carries the transformations that are not simple receiver operations: Text.trimStart, Text.trimEnd, Text.capitalize, Text.title, Text.lines, Text.lineStarts, Text.chunks, Text.words, Text.slug, Text.normalize, Text.truncate, Text.indent, Text.dedent, Text.normalizeWhitespace, Text.utf8Size, Text.escapeHtml, Text.codePoint, and Text.fromCodePoint. Blank text is tested with the text.isBlank() member — true for empty or whitespace-only text. Text.utf8Size(text) returns the exact byte count used by official UTF-8 transport, JSON, and filesystem budgets, including stable three-byte treatment of an unpaired surrogate. Text.lineStarts(text) performs one bounded scan and returns [0, ...] Unicode code-point offsets immediately after each line-feed character, including the final text size when the text ends in a line feed. This keeps large-file line indexes out of repeated .char(index) lookups without exposing JavaScript UTF-16 units. Text.chunks(text, size) performs the same single bounded code-point scan and returns non-empty pieces of at most size code points; it never splits a surrogate pair, and an empty input returns an empty List. Stateless pattern operations are Text.matches, Text.findMatch, Text.findMatches, Text.replaceMatches, and Text.splitPattern.

Text.title treats separators as word boundaries. Text.truncate reserves room for its suffix. Text.slug lowercases Unicode text, folds Latin accents, removes punctuation, and joins word runs with -. The fold is a compatibility fold: the text is decomposed as "NFKD" before the marks are dropped, so every character that carries a compatibility decomposition is replaced by that decomposition — ½ becomes 1-2, the fi ligature becomes fi, an Arabic presentation form becomes the plain letters it stands for, and Thai ำ becomes the two signs it decomposes to. Text.slug does not transliterate: a script that has no Latin spelling keeps its own letters, and combining marks that carry meaning in their own script — Devanagari and Bengali dependent vowels, Thai vowel and tone marks, Hebrew niqqud, Arabic harakat — are kept, so distinct titles do not collapse onto one slug. A run of marks with no letter, digit, or mark in front of it is dropped rather than kept, because it has nothing to sit on: a variation selector left behind by a removed emoji is invisible in a URL, and two slugs that read alike would otherwise name different pages. The result is recomposed to NFC, so a slug is not a decomposed near-miss of the text it renders as — a Hangul slug is found again as a route key, a Map key, and a Set key, and its size is the number of characters it renders as. Recomposition does not undo the compatibility fold, so text a compatibility mapping separates from its own slug does not equal it. Text.normalize(text, form="NFC") applies one of the four Unicode normalization forms — "NFC", "NFD", "NFKC", or "NFKD"; any other form throws RangeError. Text equality is code-point-sequence identity, so canonically equivalent text is not equal: a precomposed accented character and the letter plus its combining accent render identically but compare unequal, report different size, and miss each other as Map and Set keys. Normalize where such text enters the program — macOS filenames arrive decomposed while typed text is usually composed. Text.escapeHtml escapes text for HTML content and attribute contexts but does not mark it as trusted HTML. Text.codePoint(character) answers the code point of exactly one character and null for anything else — empty text, several characters, or a lone surrogate half; Text.fromCodePoint(value) is its inverse and throws RangeError for a value outside 0–1114111 or inside the surrogate range, so no call can build text that is not a sequence of characters.

Pattern expressions use JavaScript pattern syntax in Unicode mode through a captured intrinsic implementation, not a replaceable ambient RegExp global. Each operation creates a fresh pattern; source code never receives RegExp or its mutable lastIndex. Options are copied from one typed data record containing only optional ignoreCase, multiline, and dotAll booleans. findMatch returns {value, index, groups} or null; index is a Unicode code-point position, so it can be passed directly to char or slice without leaking JavaScript UTF-16 offsets. Text.findMatches returns all such records and normalizes an unmatched capture to null. Text.replaceMatches replaces every match with one literal string, and Text.splitPattern omits capture groups from the result. Invalid patterns throw TypeError at the VelarScript boundary, carrying the engine's reason for the rejection. Patterns compile in Unicode mode, so an identity escape that is tolerated elsewhere in JavaScript — \@, \- outside a character class — is an error here and says so.

Pattern source is limited to 4,096 code units, pattern input/output and returned match text to 16 MiB, and list-producing pattern operations to 1,000,000 results. Matches are copied from checked data fields, empty Unicode matches always make code-point progress, and replacement size is checked before the final string is allocated. Those are size bounds; matching time carries its own. Every operation that runs a caller-supplied pattern is given 250 milliseconds, checked after each match, and exceeds it with a RangeError rather than running for as long as a backtracking pattern likes. The engine is not interruptible, so one catastrophic match is caught when it returns rather than pre-empted; what the budget denies is the amplification a per-match loop would give that match. Text.slug, Text.title, and Text.normalizeWhitespace are deliberately not charged against it — they run this module's own linear patterns, so a large but legal text must not succeed or fail by how busy the machine is. Patterns are application code, not a sandbox for executing arbitrary user-supplied regular expressions; applications that accept search text should use the literal .has()/.startsWith()/.endsWith() operations unless they deliberately own a pattern grammar.

Text counts used by .repeat, .padStart, .padEnd, and Text.truncate are non-negative safe integers; native string-to-number coercion is not exposed. Dynamic pattern options must be plain enumerable data fields, so getters, symbols, and class instances are rejected without hidden evaluation. Text composition such as .replace, .replaceAll, Text.escapeHtml, and Text.indent check its complete output budget before allocating the final string.

The compiler-owned String runtime and the Text. runtime capture their string, array, numeric, reflection, RegExp, iterator-independent Unicode, and Error operations when the module initializes. Pattern replacement and splitting are driven by the captured native exec operation instead of mutable RegExp symbol hooks. Later changes to JavaScript globals, prototypes, or string/array iterators therefore cannot redirect a checked text operation.

const valid = Text.matches("VelarScript 42", "^velar [0-9]+$", {ignoreCase: true})
const ticket = Text.findMatch("ticket-42", "[0-9]+")
const fields = Text.splitPattern("one, two; three", " *[,;] *")
const initial = "Ada".char(0)
const short = "VelarScript".slice(0, 5)
print(f"{initial ?? "?"}:{short.size}")

Math. (permanent, no import)

The namespace exposes JavaScript Number mathematics without claiming Python integer or decimal behavior. Every operation nevertheless requires an actual number at runtime; the JavaScript Math global's coercion cannot turn "2", [], or another dynamic JavaScript value into a VelarScript number.

What belongs on a number is a number method, so what is left here is exactly what cannot be one: the constants, the multi-argument functions, and the transcendentals.

Group Members
Constants pi, e, tau, infinity
Bounds min, max, clamp
Powers and logarithms sqrt, cbrt, pow, exp, log, log2, log10
Trigonometry sin, cos, tan, asin, acos, atan, atan2, degrees, radians
Numeric helpers hypot, random, randomInt, gcd, lcm

The receiver-shaped operations are number members: .abs(), .round(), .floor(), .ceil(), .sign(), .trunc(), .toFixed(digits), and the predicates .isInteger(), .isSafeInteger(), .isNaN(), and .isFinite(). Writing a receiver operation through the namespace — Math.abs(value) — is answered with the receiver spelling to write instead. round returns a number at the nearest integer; toFixed returns decimal text with 0 through 100 digits; isInteger follows Number.isInteger, so Infinity and NaN are not integers. .isSafeInteger() also requires the inclusive JavaScript safe-integer range ±9,007,199,254,740,991. These members use a compiler-owned Number runtime that captures their Math, Number, reflection, and Error operations when the generated module initializes; later replacement of JavaScript globals or prototypes cannot redirect them. randomInt uses an inclusive lower and exclusive upper safe-integer bound; with one argument its lower bound is zero. gcd and lcm likewise own safe integers, and lcm rejects an inexact result. Randomness is the host JavaScript runtime's Math.random and is not cryptographically secure. Its result is checked as a finite number in the native [0, 1) range before either random API returns a value. The numeric constants, mathematical operations, random source, numeric predicates, Reflect invocation, and error constructors are captured when velar/math initializes. Replacing Math, Number, their methods, or the ambient error constructors afterward cannot redirect an already initialized module; a host that supplies a missing/accessor-backed operation or an invalid random result fails explicitly.

Json. (permanent, no import)

Export Behavior
parse Parses JSON; an optional VelarScript type validates and types the result.
tryParse Returns a validated value, an explicit fallback, or null.
stringify Strictly serializes JSON data with an optional compact/indent setting.
stableStringify Strictly serializes while recursively ordering record keys.
clone Strictly JSON-clones a value and optionally validates the result with a VelarScript type.
isSerializable Reports whether a value is losslessly representable as JSON data.

Content comparison is one concept with one spelling: the prelude equals(a, b) (charter section 4). It needs no import, requires its operands to intersect statically, rejects class instances, functions, Promises, and unvalidated unknown at compile time, compares Set members and Map keys structurally rather than by reference, agrees with == on NaN, and throws on a cycle rather than answering a quiet false.

Strict JSON is a compiler-owned runtime shared by Core and platform consumers. It captures parsing/serialization, Array/Set traversal, reflection and data descriptors, numeric/text/path operations, allocation, Reflect invocation, and error constructors when the generated module initializes. stableStringify uses that same captured sort operation. Replacing ambient globals or prototypes afterward cannot redirect validation, snapshots, cloning, ordering, or equality. The versioned reactive registry remains the one explicit dynamic seam used to unwrap tracked values before JSON inspection.

Every member above is a permanent Core namespace member: JSON handling is pure computation, so no Json.* call needs an import and named imports from velar/json are retired with a diagnostic that teaches the namespace spelling.

type User:
    id: string
    name: string

const user = Json.parse(source, User)
const unchanged = equals(user, previousUser)

The second argument is the existing runtime form of a record type, a runtime transparent alias, or an enum; there is no parallel schema declaration. The compiler registers those frozen Type values under one shared runtime identity, including when they cross module boundaries. parse, tryParse, and clone check that identity before parsing or cloning and never inspect an arbitrary object's is or parse fields as permission to skip validation.

JSON data is deliberately narrower than arbitrary JavaScript values: it contains finite numbers, strings, booleans, null, dense Lists, fixed records, and Record<T> dynamic string-key records recursively. Map, Set, class instances, functions, sparse Lists, accessor/symbol fields, non-finite numbers, and cyclic graphs are rejected instead of inheriting JSON.stringify's silent field omission or {} / null substitution. Known unsupported VelarScript types fail checking; unknown and unsafe JavaScript values are validated at runtime. Repeated references are allowed when the graph is acyclic. Indentation is false, true (two spaces), or an integer from 0 through 10.

Parsing and encoded output are limited to 16 MiB. Validation stops beyond 1,000,000 values or 128 nested collections, checks array data descriptors without invoking getters, and estimates pretty-print expansion before calling the host serializer. Serialization and cloning operate on the data-descriptor snapshot created during that same validation pass, so a Proxy or List subclass cannot present one value for validation and a different value to the serializer. Parsed host results are copied through the same boundary, and the serializer's return value must be actual text.

Promise. (permanent, no import)

Member Behavior
sleep Resolves after a non-negative Duration (250ms, 1s).
all Awaits a List of Promises to a List of results, or a record of Promises to a record of the same shape. A List whose elements resolve to different types is rejected in favour of the record form, so every result keeps a name.
race Settles with the first Promise in a List to settle. A runtime-empty race List throws RangeError("race requires at least one Promise") — an empty race would never settle.
timeout Rejects with TimeoutError if a Promise does not settle before a Duration; accepts an optional message, which becomes the error's message. TimeoutError is a Core built-in that needs no import, so catch error: if error is TimeoutError: tells "too slow" from "the task failed".
retry Runs a zero-argument sync/async task again after failure, up to the retry count, waiting an optional Duration between attempts.
map Maps a list with a sync/async worker and optional positive concurrency limit while preserving result order.
series Runs a list of zero-argument sync/async tasks sequentially.

Promise. is always in scope. Both spellings that reach these members through velar/async — the named import and the namespace import — are retired and receive a diagnostic that teaches the namespace spelling.

The table is the whole namespace, and the three JavaScript statics it does not carry are absent for one reason each. Promise.resolve(value) has no Vel spelling because an async def already produces a settled Promise from its result: pass the value, and the awaiting side sees it. Promise.reject(error) has none because the rejection is the throw inside that async def. Promise.allSettled(list) has none because failure is already a value here: try await task() answers null instead of rejecting, so mapping each task through it and awaiting Promise.all gives the same list of settled outcomes with no second result vocabulary. Writing any of the three earns a diagnostic that names the replacement above.

These helpers use the host Promise queue. They do not create threads, cancel a Promise, or replace the JavaScript event loop. Their List arguments use the same dense List validation as collection helpers; concurrency counts are positive safe integers. all, race, and timeout require actual Promises at runtime, including across JavaScript realms; arbitrary thenables are rejected without probing a then accessor. The module snapshots the Promise, timer, numeric-validation, reflection, and dense-List operations it needs when the module initializes. Later ambient replacement and List subclass overrides cannot redirect its work. Retry attempts are positive safe integers and timeout messages remain real strings at dynamic boundaries. all and race may start at most 10,000 operations at once, map concurrency is at most 1,024, retry is at most 10,000 attempts, and timer durations stay within the signed 32-bit host timer range.

Short asynchronous workers use the language's expression arrow directly, for example await Promise.map(urls, async url => await load(url)). Its inferred result is Promise<T>; ordinary synchronous callbacks and named async def workflows remain available for expression and block bodies respectively. All async forms share native Promise adoption: a named async worker declared -> T may return either T or another Promise<T> without adding return await merely to satisfy the checker. JavaScript reserves a resolved value's top-level then member, so retry cannot return a record/class with callable then or a class with a then getter; the compiler rejects known cases and the runtime fails closed at a dynamic callback. map and series may still collect those values as ordinary List elements because their Promise resolves to the containing List, not to an individual element, and their sync callback path never probes the element's then member.

velar/url

Export Behavior
parse Returns href, protocol/host fields, path, query Map, hash, and origin; accepts an optional base.
join Joins one or more string URL/path segments without duplicate separators while preserving a leading scheme://.
query, parseQuery Convert an object/Map to a query string or a query string to Map<string, string>.
withQuery, withHash Return a URL/path with a replaced query or hash.
isExternal Compares a URL with an optional base/current location origin.
encode, decode Encode or decode one URL component.
normalize Resolve and normalize a URL/path with an optional base.

Query names are strings. Values are explicit string/number/bool scalars, null, or dense Lists of those scalars; records/classes/functions are rejected instead of becoming "[object Object]". null values are omitted, List values produce repeated keys, and duplicate parsed keys keep their last value in the returned Map. Query numbers must be finite. URL text operations validate actual strings at dynamic boundaries rather than inheriting JavaScript String(...) coercion; this includes the browser's current base URL and every field copied from a native URL result. URL text and encoded query output are limited to 2 MiB; component encoding and multi-part joining check the resulting length before allocation. Normalization and query/hash replacement likewise budget every native URL fragment before concatenating the returned relative or protocol-relative text. Parsed names/values share the same text budget, and query maps contain at most 100,000 fields.

The URL and URLSearchParams constructors and prototype operations, Map brand/iterator operations, component codecs, numeric/text/reflection helpers, error constructors, and browser location object plus its href reader are captured when velar/url initializes. Replacing globals or prototypes afterward cannot redirect an initialized module. A real captured browser Location still reports later navigation changes; the contract fixes the capability source, not the page URL value. Hostile URL text and query iterator results continue to fail closed without invoking conversion hooks.

velar/time

Time values are Unix epoch milliseconds, matching the host JavaScript runtime without exposing mutable Date objects.

Export Behavior
now Current Unix epoch milliseconds.
monotonic Monotonic milliseconds for elapsed-time measurement.
parse Parse deterministic ISO YYYY-MM-DD or a T datetime with Z/numeric offset to epoch milliseconds, accepting RFC 3339's lowercase t/z and truncating fractional seconds to milliseconds; return null for invalid text.
iso Format epoch milliseconds as an ISO 8601 UTC string; defaults to now().
format Locale-format a time, or a chosen part of one, with optional locale, time-zone, and date/time style strings.
date Construct local epoch milliseconds from strict year, month, day, and optional time fields.
utc Construct UTC epoch milliseconds from the same strict fields.
parts Return numeric year through millisecond parts in local time or an explicit time zone.

date and utc reject out-of-range or nonexistent calendar values instead of using JavaScript Date rollover (2024-02-31 never becomes March). Years are 0 through 9999 and do not receive JavaScript's special 1900 offset for 0–99. One case is deliberately not a rejection: date(year, month, day) with no time fields names a calendar day, not a wall clock, so in a zone whose daylight-saving transition skips local midnight it resolves forward to the first instant that exists on that day rather than declaring a real day unrepresentable. date with explicit time fields still rejects a wall clock that does not exist. Where a local wall clock occurs twice — the hour a daylight-saving fall-back repeats — date returns the earlier, pre-transition instant; the later occurrence is not reachable through this API, so a program that must distinguish them should hold the instant it was given rather than rebuilding it from parts.

parse accepts date-only ISO as UTC midnight; a datetime must include Z or a numeric offset, which keeps results identical across JavaScript engines and host time zones. The offset is written +HH:MM, +HHMM, or +HH, and RFC 3339's lowercase t and z are accepted beside the uppercase spellings. Fractional seconds are read as a decimal fraction and truncated to milliseconds; the number of digits is not bounded separately, because the 64-character input limit below already bounds it. :60 is accepted where RFC 3339 writes an inserted leap second — at the end of a UTC day, whatever local hour the offset makes of it — and names the instant that follows, which is what a reader of that timestamp means; :60 anywhere else is a typo rather than a timestamp and returns null. Non-ISO/native locale text is deliberately unsupported. Locale and named-time-zone arguments must be actual strings.

format takes a dateStyle and a timeStyle, both defaulting to medium, and both drawn from the host's own vocabulary: full, long, medium, short, and none for the half a call leaves out. format(value, locale="en-US", dateStyle="none", timeStyle="short") is the time of day a message list shows, and it is the locale's time of day — 3:45 PM where the reader expects a twelve-hour clock and 15:45 where they do not. Rendering that from parts() by padding an hour and a minute by hand gives every locale a twenty-four-hour clock, which is why the styles are here rather than left to the caller. Both styles none is refused: that names no format at all. Returned timestamps are checked as finite values inside JavaScript's supported date range. Host clock and internationalization results are validated before they cross back into VelarScript: invalid clocks, non-string formatting output, missing/duplicate time-zone parts, accessors, and impossible calendar fields fail explicitly rather than leaking NaN or guessed values through the typed API. A time-zone parts List has one bounded length snapshot, so a changing host collection cannot extend validation midway. Formatted output is limited to 65,536 characters.

The wall and monotonic clocks, Date constructor and prototype operations, internationalization constructor and formatting operations, numeric/text helpers, result freezing, Reflect invocation, and error constructors are captured when velar/time initializes. Replacing those globals or prototypes afterward cannot redirect an initialized module. Invalid host clock and Intl results are still checked at the operation boundary; locale and time-zone data continue to come from the host rather than being snapshotted or reimplemented.

import {iso, now, parts, utc} from "velar/time"

const launched = utc(2026, 8, 1)
print(iso(launched))
print(parts(now(), "UTC").year)

Months are one-based. ISO parsing is owned and deterministic; locale formatting and named-time-zone projection still use the host internationalization data. VelarScript does not invent a second timezone database or datetime object model. ISO input longer than 64 characters is invalid without entering the matching engine. Locale and time-zone names are limited to 1,024 characters before the host internationalization API is called. Explicit time-zone projection also keeps astronomical year numbering (1 BC is year 0), matching JavaScript Date and the local parts result.

velar/id

uuid() returns a cryptographically secure host UUID. It delegates to the existing JavaScript host's crypto.randomUUID() and fails explicitly when that capability is unavailable or returns a non-canonical result; it never falls back to timestamps or Math.random. The capability must be a data method rather than an accessor, and non-Error host failures are wrapped without invoking conversion hooks. The crypto object, data method, matching operation, and error identity are captured when velar/id initializes; replacing globals or prototypes afterward cannot redirect generation or validation. isUuid(value) checks canonical UUID text without changing it and rejects non-36-character input before pattern matching. It answers the textual question only — 36 characters, hyphenated 8-4-4-4-12, hexadecimal in either case — so the nil and max UUIDs of RFC 9562 and GUIDs from variants other than RFC 4122 are accepted as the canonical text they are. It is not a claim about which version generated the value.

import {isUuid, uuid} from "velar/id"

const taskId = uuid()
print(isUuid(taskId))

velar/log

Structured logging replaces direct source-level access to console while remaining on the existing JavaScript runtime.

import {LogRecord, logger, setLevel, useSink} from "velar/log"

def sendRecord(record: LogRecord): postToCollector(record.scope, record.level, record.message)

component BuildStatus:
    const buildLog = logger("build", Map({target: "web"}))
    const stopCapture = useSink(sendRecord)

    @mounted:
        setLevel("debug")
        buildLog.info("Compilation ready")

    @cleanup:
        stopCapture()

    return <p>Ready</p>
  • log is the unscoped logger; logger(scope, fields=Map()) creates a scoped logger with optional base fields. The fields are written as an ordinary literal — Map({target: "web"}) — because a Map(...) in a position that already says what the Map holds takes that type, exactly as [] does. Scope/message/field names remain actual strings at dynamic boundaries; logging never calls ambient String(...) on invalid input.
  • Loggers provide debug, info, warn, and error. Fields are Map<string, unknown> values; error optionally receives an Error and additional fields.
  • setLevel accepts debug, info, warn, error, or silent; level returns the current threshold. The threshold is read on every call, so setLevel reaches loggers that already exist.
  • A call below the active level returns before it builds anything: the logger's context is not merged, the message and fields are not validated, and no bound is checked. Turning a level off is therefore free and side-effect-free, and a suppressed call cannot raise. The message and field bounds below apply to records that are actually emitted, and the runtime check that error(...) received an actual Error sits behind the same gate — the compiler still requires one at that position and a boundary value arrives as unknown, so only an extern JavaScript contract that declared an Error can hand it something else.
  • useSink(sink) redirects records while at least one custom sink exists and returns an explicit cleanup function. The record it hands a sink is LogRecord, a published type name: timestamp, level, scope, message, fields, and an optional error. A sink is therefore either a named def with an annotated parameter, as above, or an arrow that useSink types contextually — the same choice velar/serve gives a handler through ServeRequest.
  • LogRecord is a runtime value as well as a type name, so LogRecord.is(value) answers whether a value is a record and LogRecord.parse(value) validates one at a dynamic boundary. That is the surface velar/fs publishes for FileWatchBatch.
  • Each sink receives its own fields snapshot, so mutation inside one callback cannot rewrite what another sink observes. The optional error is either an actual Error or null. Rejections from an actual Promise returned by a sink are reported internally; arbitrary objects are not treated as thenables and their then accessors are never probed.
  • One logger accepts at most 1,000 merged fields, field names are limited to 1,024 characters, messages to 64 KiB, scopes to 1,024 characters, and an application may install at most 1,000 sinks. These limits are checked before dispatch; native Map iteration is used without invoking subclass overrides.
  • Record timestamps are accepted only when the host clock returns a finite value inside JavaScript's date range. The fallback console must expose data methods; logging will not execute accessor properties to discover a writer.
  • Without a custom sink, the runtime writes through the host console internally. VelarScript source still receives no console global. Sink failures fall back to the internal host logger and cannot recursively invoke the failing sink.
  • The clock, collection operations, Promise rejection observer, string normalization, error identity, and fallback console writers are captured when velar/log initializes. Ambient replacement afterward cannot redirect log delivery; setLevel and useSink are the explicit customization points.
  • VelarScript never uploads logs or telemetry automatically.

Local platform modules

This chapter is the reference for the Node, Server and Desktop-shared platform modules: there is no separate node-api.md or server-api.md, by design. Web has its own web-api.md because its vocabulary is a second language surface — components, reactivity, Look — while these modules are ordinary functions and types over one capability each, and splitting them out would separate them from the Core rules they are read against.

The Desktop API reference covers Desktop-owned windows, system integration, supervised services, notifications, secure storage, and the browser-test host seam. The shared scoped platform APIs stay in this chapter.

Standard API 0.5 adds a small first-party surface for local applications and servers. Node is the current internal engine, but Node classes, callbacks, events, buffers, and overloads are not part of the VelarScript contract. These modules work under velar run, Core tests, and Core builds. A Web project that imports a Node-only module fails during project compilation; velar/serve points Web code to the application dev server and the browser target of velar/http. Their contracts and implementations are owned by the independently reusable @velarscript/node package; the CLI only composes that extension for local programs.

Errors these modules raise. Only velar/fs, velar/serve, and velar/http name their own error classes, because only their failures have more than one recovery. Every failure of velar/path, velar/process, velar/env, velar/host, and velar/terminal — a wrong argument, an exceeded budget, an unusable host — has exactly one recovery, which is to change the code, so each arrives as an ordinary Error. A class exists only where a caller would write different recovery for it.

velar/serve

The Node extension's native serving capability declares an immutable route table instead of a controller class or a group of decorated functions:

import {HttpProblem, Request, created} from "velar/serve"

type CreateArticle:
    title: string

export server app:
    @get health(p"/health") => {ok: true}

    @get readArticle(p"/articles/{id:number}?{details:bool?}"):
        if id < 1:
            throw HttpProblem({status: 404, reason: "article.not_found", title: "Article not found"})
        return {id, details: details ?? false}

    @post createArticle(p"/articles", input: CreateArticle) => created({id: 1, title: input.title})

    @notFound(request: Request) => {error: "route_not_found", path: request.path}

server name: and the Node-only p"..." path pattern are enabled by @velarscript/node. @get, @post, @put, @patch, and @delete are the route roles; @notFound is the one unmatched-path fallback on the final application; @response is its one semantic-result policy. They are compiler declarations, not decorators or runtime functions. A {name:type} An inline pattern projects path captures and query fields as immutable handler locals. A referenced pattern uses @get(pattern as route) and receives a RouteMatch with route.pattern, route.pathname, route.params, and route.query. A query type ending in ? is optional, while every other field is required and rejected before the handler when absent. The type is string, number, bool, or a named enum. str(route) and str(route.pattern) return the complete route declaration. ?wire={field:type} maps distinct names; writing ?field={field:type} is accepted but advisory A11 offers the mechanical ?{field:type} shorthand. One concrete Data record on POST, PUT, or PATCH is the JSON body. Request asks for the complete low-level request explicitly. Route bodies may await directly and use either => expression or an indented block.

An optional identifier after the role gives the route a stable operation identity, for example @get readArticle(...) or @websocket worldRealtime(...). The identity is unique across the final composed app and is preserved by prefix. OpenAPI emits it verbatim. Anonymous routes remain valid and receive a method/path-derived identity for documentation only.

@notFound accepts zero parameters or one explicitly typed Request. Ordinary Data becomes a 404 JSON response; an explicit ServeResponse keeps its selected status. A matched route's HttpProblem and framework 405 response remain outside this fallback. It participates in application middleware and lifecycle handling, but cannot be carried through a non-root prefix; declare it after composing prefixed route tables. A catch-all route such as staticFiles("/", ...) is already a matched route and retains ownership of its own file fallback.

Returning ordinary Data produces a negotiated semantic response. respond(value, status=200, headers=null), created(value, headers=null), and noContent(completion=null, headers=null) select semantic status. json(value, status=200, headers=null), redirect(location, status=302, headers=null), text(value, status=200, contentType="text/plain; charset=utf-8", headers=null), stream(producer, status=200, headers=null), and file(path, root=".", fallback=null) express the response cases whose status or transport should be visible. HttpProblem(options) exits a route with a checked 4xx/5xx problem; the default encoder uses application/problem+json. Its semantic code is reason: charter section 11 owns code on every checked Error as that instance's class name, so HttpProblem.code is "HttpProblem" and reading it on an HttpProblem is refused with reason named as the successor. The wire problem document is unchanged — it publishes that reason under its JSON field name code, which is what clients and openapi() already read. stream(producer, status=200, headers=null) sends exactly the headers it is given and nothing else: it does not guess a media type, so a streaming route sets its own content-type — the framework will not, and a client left to sniff one is a client that will eventually sniff wrong. Returning null is returning Data: it is a negotiated 200 whose body is the JSON value null. The way to answer "there is nothing to send" is noContent(), which is a bodyless 204. One application-wide @response(outcome: HttpOutcome, request: Request) policy may map every semantic success and failure to shared Data or one final response. A final response owns its status and headers; a policy that uses json or text only to select an encoder therefore passes through outcome.status and either outcome.headers or, for a problem, outcome.problem.headers. The policy is global and cannot be scoped with prefix; compose it on the final application. prefix(path, app) and server-body ...app entries compose route tables, and a composed table is refereed before it can answer anything. The compiler judges whatever the composition resolves to inside one module — a spread naming a server declared there, an alias of one, and prefix with a literal path, use, bodyLimit, docs, or lifecycle around one — and refuses two routes of one method that collapse to the same path shape, or that overlap with neither more specific. An imported server or a computed prefix path resolves to nothing there and is let through, because a false conflict would block a correct program; assembling the ServeApp then applies the shape test to the finished table, and two routes of one method that collapse to the same shape refuse to build, naming both routes and where each came from. staticFiles(path, root, fallback=null) adds a bounded, root-contained streaming route with HEAD, validators, and single byte-range support. A relative root, here and in file(path, root, fallback=null) and fileResponse(root, path, fallback=null), resolves against the project root the build knew — the directory holding velar.json, which is where the author wrote "public" — so the same build serves the same files whatever directory it is started from, and velar run, which compiles into <project>/.velar/, serves the same files a directory build does. An output that does not sit inside that project — copied away after the build, or written to an --out-dir outside it — falls back to the emitted entry's own directory, so a deployment carrying its assets beside the entry keeps serving them. An absolute root is used as given. Which of the two it is, is decided once when the program starts and by identity rather than by whether a directory happens to be there: the build bakes the project's identity into the emitted module, and the project root answers only when the velar.json standing at that place is this project's. That identity is the manifest's name when it declares one, and the SHA-256 of the manifest's own text when it does not — a digest nothing but that file produces, which is why a project with no name is as safe from a stranger's directory as one with a name. Every other outcome falls back to the entry's own directory and says so once on stderr, naming both identities: a velar.json that is there and belongs to another project, which is what a dist/ copied next to somebody else's public/ gets, and — because a nameless project is identified by its manifest's bytes — a velar.json that was edited, deleted or made unreadable after the build. Each of those reports is a notice about how the output is deployed, not an error the program raised: rebuilding the output beside its project makes it stop. Declaring a name is what makes a manifest editable without a rebuild. One consequence is worth stating, because it is invisible locally: while the output still sits inside its project, the project root always wins, so a dist/ in the tree never reads its own dist/public/. velar build copies public/ into the output for the deployment to carry, and any post-build processing of dist/public takes effect only once that directory has been moved away from the project. A relative root names a directory inside the project. One carrying .. escapes it, and is refused: by the build when it is written as a literal, and by velar/serve otherwise — when the route is declared for staticFiles, and when a route reaches it for file and fileResponse. Name a directory outside the project with an absolute root. A root that names nothing at all is not refused: when the build recorded where the project root is, every root the application declared before the server starts is checked once then, and one that does not name a directory is reported on stderr with the path it resolved to, so a misspelled "pubic" is distinguishable from a file that is missing. The requests themselves answer the 404 they always did, and a root first named inside a handler is checked no earlier than that handler runs. bodyLimit(app, maxBytes) narrows inferred JSON input for that route group, and use(app, middleware) wraps only that app's routes after composition. A middleware next() continuation is single-use. Every ending a route can have is a response by the time it leaves that route, so middleware shapes all of them: a thrown HttpProblem, a framework rejection such as 405, 415, or 422, and an unexpected error narrowed to the opaque 500 carry the same securityHeaders, cors, requestId, and access-log treatment a 200 does — which is what makes an error response readable across origins. The framework's own 404 for a path no route claims leaves through the middleware use attached to the application, whether or not the application declares a @notFound fallback. middleware.errors(handle) keeps its recovery role: it is offered the error a route ended with, wherever it sits in the chain, and its result becomes the response. openapi(app, title=null, version="1.0.0") derives an OpenAPI 3.1 document with checked parameter, request-body, and response schemas. It also documents the framework-generated 400, 401, 413, 415, and 422 responses that apply to each route; explicit RouteDocumentation.errors entries override their descriptions. Typed path syntax such as p"/articles/{id:number}" is rendered as /articles/{id} in that document. Every route publishes the success response its declared status and return type describe, and no judgement is made about whether a handler can reach it: a route whose body only throws still publishes its success response — 200 with the null schema its return type gives — and the statuses it throws appear only if the route documents them through RouteDocumentation.errors. Declarative WebSocket routes appear in the same paths table as GET upgrades, with response 101 and x-velar-transport: websocket; an HTTP GET and WebSocket upgrade cannot share one documented path.

The separate @velarscript/server application extension composes this Node capability. It adds velar/server, explicit server.configuration loading, application(app), typed configuration(Type), request-scoped authenticate(credential, verify), and an abstract database(connect, disconnect) provider lifecycle. Authentication accepts only Node-owned security descriptors; its nullable async verifier produces a typed identity Provider, and null produces the descriptor's opaque 401 challenge. Token, session, password, identity-store, and authorization policy implementations are installed library or application responsibilities. Its selected entry starts and awaits the transport inside @main; no exported startup binding or hidden launcher is part of the application contract. velar dev watches and restarts the last-good build, velar serve runs checked source with production behavior, and velar build emits a standalone Node directory with the same declared configuration. Each of them bakes the same project root and project identity a Node build bakes, so a relative static root in a Server application resolves by the rule stated above — against the directory holding velar.json — under velar run, velar dev, velar test and velar build alike; this is the one emitted velar/serve, not a second one. Direct serve(...) is the lower-level operation for tests, embedded servers, and handler adapters.

Explicit route defaults cover the inputs that cannot be inferred: input.header, input.cookie, input.form, input.upload, and input.dependency. Security descriptors include API key, Basic, Bearer, OAuth2, and OpenID. provide resolves request- or application-scoped dependencies once, detects cycles, optionally initializes app providers eagerly, and invokes release callbacks before clearing the ended scope’s cache. supply(app, provider, value) attaches an already constructed value to an app-scoped provider without process-global state; the compiler checks the provider's concrete result type and the provider's release callback retains ownership at shutdown. lifecycle, background, cookie helpers, sse, and velar/websocket.listen({http: app, ...}) cover owned service lifetime and realtime transport without adding compiler-owned names. velar/server also publishes applicationConfigurationPath, the server.configuration path this build was compiled with, as the program reads it: the same text velar.json declares, so a program can name its own configuration file in a log or a health response without restating it. Its velar/realtime half is the server side of the typed session the Web module documents: realtimeSession(...) runs it, RealtimePeer<T> is one connected peer, and RealtimeCodec<Incoming, Outgoing> is the pair of decode(message) and encode(message) functions that turn wire frames into the two message types a session declares. RealtimePeerState — open, closing, closed — is what peer.state() answers, and RealtimeBackpressureError is what a send raises when the peer's pending-byte budget is full; trySend answers false in that same situation instead of raising. Lifecycle hooks are paired: successful startups unwind in reverse order, while a hook whose startup failed never receives a shutdown call. Multiple cookies remain multiple Set-Cookie fields rather than being comma-joined.

Built-in middleware covers CORS, trusted hosts, request IDs, access logs, security headers, compression, error recovery, timeouts, and concurrency. docs composes a bundled offline UI and OpenAPI endpoint; its optional ordinary Map adds summary, tags, success/error statuses, or visibility without changing the single role of @. The test-only velar/server-test module runs routing, lifecycle, provider overrides, cookies, multipart uploads, streams, and files in process: await client(app, overrides?) answers a TestClient whose get, post, put, patch, delete and request return a TestResponse — status, headers, text() and json() — and whose close(grace?) ends the application's lifecycle. No port is bound and no socket is opened.

GET routes answer HEAD automatically without writing the response body. OPTIONS and 405 responses publish GET, HEAD, and OPTIONS consistently, and 204 and 304 responses are always bodyless at the transport boundary. Public response and documentation statuses are final HTTP statuses from 200 through 599; an application cannot emit an informational response as its final result. Path and query number inputs use finite decimal syntax rather than JavaScript coercion, so whitespace and hexadecimal text are rejected with 422. An enum capture whose wire values are all integers — the form D102 introduced, and the one its OpenAPI schema states as {"type":"integer","enum":[1,2]} — decodes by that same rule before its membership is checked, because the URL segment is text and the members it must match are numbers. It is exactly the number rule and no more: what a {n:number} capture accepts is what such a capture accepts, a decoded value that is not a member is the same 422 an undecodable one is, and a capture whose enum is all strings, or whose wire values mix strings and integers, matches the raw text as it always has. An inferred JSON body accepts a missing media type, application/json, or an application/*+json type; another declared media type returns 415 before body decoding.

serve(app, port, host="127.0.0.1", maxBodyBytes=16777216) binds a declared ServeApp and resolves to a Server record containing the actual port and an idempotent async stop(grace=30000). Stop closes admission, requests cooperative cancellation, drains admitted work until the bounded deadline, and only then releases application providers and lifecycle hooks. For low-level protocol adapters, serve continues to accept an async handler. The handler receives a ServeRequest with method, a once-decoded canonical URL path, first-value query and repeated-value queryAll Maps, normalized headers, and a request cancellation. Invalid percent-encoded UTF-8, encoded path separators, NULs, and dot segments are rejected before routing, and a path parameter matches one non-empty segment: /n/ does not match /n/{id:number} at all, so it is a 404 rather than a 422 about an id the request never supplied. It also provides cached async text(maxBytes=16777216) and json(maxBytes=16777216) body readers. The first read enforces its byte budget while the request arrives, before accumulating a larger body; a later read may impose a smaller budget on the cached body. parse(Type, maxBytes=16777216) applies json() and then validates the result with a compiler-known VelarScript runtime Type, returning Promise<T> with the same inference and callable-then rejection as velar/http. The Type identity is checked before any body read, so an invalid or forged Type cannot consume the request stream. Errors it raises. serve reports a port already bound as AddressInUseError (bind another port, or 0 for any free one) and a port the host refuses as PermissionError; both are nameable Core classes that need no import. Budgets are positive integers up to the 16 MiB hard ceiling. Exceeding the application budget throws RequestBodyTooLargeError, whose read-only maxBytes field lets a handler return 413 without matching error text. Request text must be valid UTF-8. json() then applies the same finite, bounded, accessor-free JSON contract as velar/json and every velar/http target; native JSON.parse values such as an overflowed 1e400 never enter Vel as Infinity. No byte that decodes is removed: a leading U+FEFF reaches text() as the character it is, and json() and parse() therefore refuse a BOM-prefixed body exactly as Json.parse does, with the framework's own 400 request.invalid.json problem.

import {RequestBodyTooLargeError, ServeRequest, ServeResponse, fileResponse, serve} from "velar/serve"

type MessageInput:
    text: string

async def handle(request: ServeRequest) -> ServeResponse:
    if request.path == "/api/health": return {status: 200, json: {ok: true}}
    if request.path == "/api/message":
        try:
            const input = await request.parse(MessageInput, maxBytes=65536)
            return {status: 200, text: input.text}
        catch error:
            if error is RequestBodyTooLargeError: return {status: 413, json: {error: "Request body is too large"}}
            throw error
    return fileResponse(root="dist", path=request.path, fallback="index.html")

const server = await serve(handle, port=8787)

A response is one checked plain record body:

  • {status, json, headers?} serializes bounded, finite, acyclic JSON data without invoking getters or toJSON hooks;
  • {status, text, contentType?, headers?} sends at most 16 MiB of UTF-8 text;
  • {status, stream, headers?} runs an async producer whose awaited write accepts at most 1 MiB per chunk and 64 MiB in total. write follows transport backpressure and rejects if the client connection closes, so a producer can release its upstream request in finally instead of remaining suspended.

The exported ServeRequest, ServeResponse, and Server runtime types inspect only enumerable own data fields. Type checks and response dispatch never invoke getters, symbol hooks, toJSON, overridden collection methods, or sparse List entries. A Server JSON response takes one compiler-owned strict JSON snapshot when the response is created; finalization, middleware copies, test transport, and native transport carry that same encoded snapshot instead of walking and serializing the application value again.

The application-facing module never owns a Node HTTP server or request stream. It exchanges bounded request/response commands with the same private isolated Node host used by velar/fs; only that Worker imports node:http, node:fs/promises, and Node stream machinery. Its port is unreferenced while idle and referenced while an operation is pending or a server is active. Live server and request identities are bounded and collision-free across handle wraparound. Per-value limits do not multiply without bound under concurrency: the Worker has one 128 MiB aggregate budget for cached request bodies, static files, buffered text/JSON responses, and in-flight stream chunks. Completion and disconnect return stable request ownership only after concurrent host operations settle; a stream write owns a separate temporary reservation until its flush or failure. This private compiler dependency is materialized transitively and cannot be imported as a Standard API module.

The count side is bounded too: at most 4,096 inbound requests, routes, lifecycle pairs, and middleware concurrency slots, plus 256 request providers, 512 app providers, and 256 unfinished timed-out requests. OpenAPI JSON cannot exceed 16 MiB. Request caches and upload views are cleared after response/background completion; app provider caches are cleared after release and shutdown. WebSocket has its own 128 MiB aggregate budget plus global active connection, pending connection, queued message, and pending-send counts, so zero-byte messages cannot evade backpressure by consuming objects instead of bytes.

A producer that fails after its first chunk cannot retract what the client has already read, so the server ends the connection with a FIN and no terminating chunk. A conforming client sees a truncated chunked body and reports it as the transport failure it is; the framework does not append a trailer that would make a partial response look complete.

Every refusal the framework makes once a request exists is one wire form: a problem document with application/problem+json, matching what openapi() publishes. That covers 413 for a body over budget, 415, 400 for a path that is not a path, and the static routes' 404 and 416. Only a transport preflight failure — a request line or header block that never became a request, such as 414 or 431 — answers with one line of text/plain.

Transport-owned headers cannot be overridden. Handler failures are reported to stderr and return an opaque 500 Internal server error; no development stack is disclosed. A client that goes away before its response completes is not a handler failure and does not use that wording: it is reported on the same channel as its own line, Client closed the connection before the response completed <method> <path>, so a stopped download and a bug in a route are distinguishable in a log. fileResponse(root, path, fallback=null) resolves a relative root against the one application root base settled at startup — the project root the build knew, or the emitted entry's own directory when the project standing there is not this one — then resolves the real root and target, rejects decoded traversal/backslashes/symlink escape, reads only regular files up to 64 MiB, and owns the static content-type table. The optional fallback goes through the identical containment and size checks. Upload.save(path, root) reads a relative root by that same rule, and an upload still cannot land outside the directory that resolves to. An Upload also reads without saving: name is the form field it arrived under, filename what the client called it, contentType what the client declared, size its byte count, and text() and bytes() its content — each bounded by the request's body limit.

RoutePattern is the type a p"/orders/{id:number}" literal has, so a route table can name one; it is written as that literal, never constructed. setCookie(response, name, value, path="/", httpOnly=true, secure=true, sameSite="lax", maxAge=null) and clearCookie(response, name, path="/") add and expire one Set-Cookie field on a response the route is already returning; each answers a new response rather than mutating the one it was given, and multiple cookies stay multiple fields.

velar/fs

Export Behavior
readText(path, maxBytes=16777216) Reads one valid UTF-8 regular file under an explicit byte budget.
readBytes(path, maxBytes=16777216) Reads one regular file as Bytes under an explicit byte budget, for content that is not text.
createText(path, text) Atomically creates one new UTF-8 file and refuses every existing entry, including symbolic links.
replaceTextIfMatches(path, expected, replacement) Replaces a matching UTF-8 file as one complete directory-entry commit and reports a detected conflict as false.
createBytes(path, bytes) The createText rule for binary content: creates one new file, refuses every existing entry.
writeText(path, text) Writes at most 16 MiB of UTF-8 text.
writeBytes(path, bytes) Writes at most 16 MiB of binary content.
appendText(path, text) Appends at most 16 MiB of UTF-8 text.
exists(path) Resolves to false only for a missing path; permission and host failures remain errors.
list(path, maxItems=100000) Returns a sorted, caller-bounded List with at most 2 MiB of name text.
info(path) Returns bounded {name, kind, size, modifiedAt} metadata, or null when absent.
canonical(path) Resolves the host real path for containment and identity checks.
makeDirectory(path) Creates the requested directory and missing parents; an existing directory succeeds unchanged.
copyFile(source, target, replace=false) Copies one regular file; replacement is explicit.
move(source, target, replace=false) Moves one path; replacement is explicit.
removeFile(path) Removes one file and never recursively removes a directory.
watchFiles(path, recursive=false) Creates a bounded, resource-owned invalidation watcher for one existing file or directory.
FileWatcher.next() Pulls one sorted/deduplicated FileWatchBatch, permits only one active pull, and returns null after release.
FileWatcher.close() Idempotently releases the watcher and settles a pending pull with null.

Errors it raises. A failure a caller recovers from differently arrives as its own class (charter section 11): FileNotFoundError when the path is absent, PermissionError when the host denies access, NotADirectoryError when a path component or a list target is a file, and FileExistsError when createText, copyFile, or move would overwrite without replace. Each carries the failing path: string?. Every other failure — a wrong argument type, an exceeded budget, an unusable host — stays an ordinary Error.

readText returns the file's text as the file spells it. A leading U+FEFF is text like any other character: it is kept, counted, and hashed, so sha256Text(await readText(path)) is the digest of the file. makeDirectory is idempotent for a directory that already exists and raises FileExistsError when the path is an existing file.

Paths are non-empty, NUL-free strings of at most 4,096 code units. The module has no synchronous forms, recursive deletion, byte inspection, callback event surface, or public filesystem streams. FileWatchBatch contains an ordinary List<string> named paths and a rescan flag. It is deliberately an invalidation stream rather than a lossless mutation log: paths are bounded, absolute, sorted, and deduplicated; an unknown native filename or exhaustion of the 4,096-path/2 MiB queue becomes {paths: [], rescan: true}. Each host owns at most 128 watchers. Native failure is terminal, and callers explicitly close the watcher when its consumer stops.

A watcher reports only the changes that happen after it is armed. watchFiles resolves when the request is accepted, not when the host has finished arming its native stream, and the stream begins at the moment arming completes. A change written between those two points can therefore never arrive — measured on macOS FSEvents under load, 4 of 40 notifications were lost, one with a 25-second window. This is the real semantics of every filesystem watcher, so the module states it rather than pretending to erase it. If you need to observe a change you are about to make, write first and then start watching, or query the state on both sides of the write. A watcher is for changes another actor makes; it is not a delivery receipt for your own. A watcher reports your own writes too, and has no self-invalidation cap. A file the program itself writes into a watched directory triggers the watcher exactly as any other actor's write does, for as many rounds as the program keeps writing. This is deliberate: a build tool writing its output into a directory it also watches is a legitimate program, and a cap would break it. A watcher that must not observe its own effects is one the program stops, or filters by path, before writing.

createText is the no-clobber primitive for generated files, approvals, and other check-then-create workflows. Its exclusive-create decision and file creation are one host operation; callers must not emulate it with exists/info followed by writeText, because another actor can occupy the entry between those calls. replaceTextIfMatches is the optimistic edit primitive. File mutations that address the same canonical target through one runtime host participate in one coordination queue. After an exact byte match, a same-directory temporary file is renamed over the target so readers observe the old or new complete contents, never a partial replacement. A mismatch returns false without writing. This contract does not pretend to be a portable operating-system CAS: an unrelated process that bypasses this API can still write between comparison and rename, and applications that coordinate such processes need their own shared lock or repository transaction. Node initializes the module's path, number, UTF-8 encoder/decoder, typed-byte, Promise, reflection, and immutable-result operations once. It validates and assembles only Velar values in the application Realm, then delegates filesystem effects to the private isolated Node host shared with velar/serve. Only that Worker imports node:fs/promises and callback node:fs watcher machinery; later replacement of application-Realm filesystem modules, Promise.prototype.then, typed-array sizing, or captured validation operations cannot redirect the public API.

Desktop applies two distinct symlink rules. Content operations such as read, write, append, copy, list, and canonicalization follow the target only after its real path remains inside a granted root. Entry operations such as info, move, and removeFile operate on the final directory entry itself, so removing or moving an in-root symlink never mutates its target. A dangling symlink cannot be reclassified as an absent write target. Recursive makeDirectory authorizes the nearest existing ancestor, permits only in-root intermediate links, and refuses a final symlink target.

velar/hash

sha256Text(text) returns the SHA-256 digest of the text's UTF-8 bytes as exactly 64 lowercase hexadecimal characters. Input is limited to 16 MiB of UTF-8 text. The module is a synchronous Core operation shared unchanged by Node, Web, and Desktop; it exposes neither an algorithm string nor a mutable host Hash object. Identifiers continue to use velar/id, and reproducible non-cryptographic streams continue to use velar/random.

The runtime implements SHA-256 over Core's bounded binary buffers and captures the string/reflection operations used for UTF-8 encoding when the module initializes. Later replacement of ambient reflection or string prototype operations cannot redirect a digest. A missing intrinsic, wrong argument type, or exceeded input budget fails explicitly.

velar/validation

velar/validation composes target-neutral semantic rules after structural parsing. Runtime Type<T> values remain the single source of truth for record, collection, enum, optional, and primitive shape; this module does not introduce a second schema language or perform coercion, defaults, transforms, I/O, or environment lookup.

integer(minimum=null, maximum=null, message=null), safeInteger(minimum=null, maximum=null, message=null), finite(message=null), and nonBlank(maximum=null, message=null) create primitive rules. refine creates an application predicate rule; field, each, optional, and all compose rules while preserving field and List-index paths. inspect(value, rule) returns every issue, and validate(value, rule) returns the same typed value or throws ValidationError for the first issue.

integer uses the same predicate as number.isInteger; safeInteger uses number.isSafeInteger. Their optional minimum and maximum are finite inclusive bounds and may have fractional values; minimum cannot exceed maximum. Bounds are an additional constraint, so the interval [0.1, 1.9] accepts integer 1. Use safeInteger when exact integer arithmetic within the safe range is part of the domain contract; collection APIs separately enforce their own index/size bounds.

parse(value, Type, rule=null) first calls Type.parse and then applies the semantic rule. safeParse performs the same work without turning invalid input into an exception: it returns {success, value, issues}. validator(Type, rule=null) binds those four operations as parse, safeParse, validate, and inspect for reuse at an owned boundary.

import {all, field, integer, nonBlank, validator} from "velar/validation"

type ServerOptions:
    host: string
    port: number

const ServerOptionsValidator = validator(ServerOptions, all([
    field("host", (value: ServerOptions) => value.host, nonBlank(maximum=255)),
    field("port", (value: ServerOptions) => value.port, integer(minimum=0, maximum=65535)),
]))

const input: unknown = {host: "127.0.0.1", port: 3000}
const options = ServerOptionsValidator.parse(input)

ValidationError.path and each issue's path use the shared readonly ValidationPath. ValidationPathKind discriminates readonly path segments: field carries a name; listIndex, mapKey, mapValue, setElement, and recordEntry carry a nonnegative index in the relevant collection order. truncated is a final marker for exhausted diagnostic effort. These types are exported as ValidationPathKind, ValidationPathSegment, and ValidationPath.

Structure and semantic rules retain the deepest determinable failure path. A nested field can therefore be reported as field(items), listIndex(0), field(name), and displayed as value.items[listIndex:0].name. The type expected at that location belongs in the message. The optional field convenience property derives from the final field segment; it does not store another path. Manual ValidationErrors without a location have the empty root path.

Paths contain at most 64 segments; messages at most 4096 UTF-16 code units; additional diagnostic traversal visits at most 4096 nodes. A truncated result keeps the known prefix and explicitly reports its limit. These limits do not change which successful values the validator accepts. Field descriptors and collection checks do not execute getters or call user value-conversion methods. An ambiguous union failure stays at the union position; a discriminator that selects one branch allows the path to continue into it. Opaque class and Promise checks remain at their own position.

field(name, select, rule) takes a label and a selector, and they are two separate things: name is the path the issue is reported under, and select is what the rule is applied to. They are normally the same field, and nothing checks that they are, so field("port", (value: ServerOptions) => value.host, ...) reports a failure of host under the path port.

Rule lists, paths, messages, and issue aggregation are bounded. The runtime captures the host operations it uses when the module initializes, and the module is shared unchanged by Node, Web, and Desktop.

velar/path

resolve(parts=[]), join(parts=[]), normalize, relative, dirname, basename, extension, and isAbsolute expose checked host-path operations; multi-part operations take a dense List<string> of enumerable data values. Every input path and every public path result is limited to 4,096 code units; many individually valid parts cannot manufacture an oversized result. On the current macOS Desktop target these operations use the same POSIX lexical semantics as the Node target on macOS. In particular, dirname, basename, and extension inspect the supplied path rather than normalizing . or .. away first, while normalize, join, resolve, and relative perform their named normalization steps. The two targets are kept aligned by a differential corpus covering duplicate separators, dot segments, hidden names, trailing separators, absolute paths, and relative pairs. contains(root, target) performs a lexical containment check suitable for the first gate of a workspace policy; code that crosses symbolic-link authority also checks velar/fs.canonical before granting access. toFileUrl(path) -> string resolves a path and returns its encoded local file: URL. fromFileUrl(url) -> string performs the inverse operation for a local file URL and rejects other schemes; Desktop additionally rejects credentials, ports, queries, fragments, non-local hosts, and encoded path separators. These functions are the public boundary between filesystem paths and LSP/document identifiers, so applications do not reproduce percent encoding or platform URL rules.

velar/process

start(command, args=[], options={}) -> Promise<Process> and run(command, args=[], options={}) -> Promise<ProcessResult> execute one program directly, without a shell. Options may set cwd, an explicit env, UTF-8 stdin, timeout, and maxOutputBytes. A child receives only the runtime's small safe environment baseline plus the explicit map; the parent environment is not copied wholesale, so unrelated API keys cannot leak by default. Process exposes read-only pid, pull-based next(), terminally idempotent wait(), and stop(). Each successful pull returns {channel: ProcessOutputChannel, text: string}; the official enum has stdout and stderr members. A process therefore uses the language's normal asynchronous iteration protocol rather than a callback or JavaScript iterator:

import {ProcessOutputChannel, start} from "velar/process"

const child = await start("git", ["status", "--short"])
async for output in child:
    if output.channel == ProcessOutputChannel.stdout:
        print(output.text)
const result = await child.wait()

Chunks preserve the order in which stdout and stderr data is observed by the host. Each channel has its own incremental UTF-8 decoder, so a code point split across native chunks is never exposed as replacement fragments. Only one next() pull may be active. Incremental output must be drained before wait() starts; calling wait() directly remains the explicit aggregate-only path. After stop(), callers obtain the terminal capture through wait() rather than starting a new output read. stop() is idempotent after confirmation and retryable after rejection. A stop request permanently closes incremental output for that handle, but a transport failure or an unconfirmed termination does not discard the handle or cache one rejected stop Promise. The Node host sends SIGTERM, escalates to SIGKILL after two seconds, and rejects after a five-second confirmation deadline if inherited pipes or an escaped descendant still prevent close; a later stop() retries forced termination against the same owned handle. Desktop applies the same retry contract across its bounded capability bridge. wait() follows the same ownership distinction. A confirmed result or process failure is terminal, releases the host handle, and is cached for later calls. A bridge failure or a five-second failure to confirm termination leaves the handle owned, clears only that in-flight wait() Promise, and permits a later wait() or stop() to retry. A later wait after a retained outcome reissues forced termination before starting a new confirmation window. Concurrent waits still coalesce into one host request. The host uses a private {result,error,retained} envelope to preserve that distinction; it is not a VelarScript value. run() cannot return its temporary Process owner to application code, so a non-terminal wait failure transfers that owner to a runtime cleanup task which keeps retrying stop() until the host confirms release. A convenience call therefore cannot orphan a child merely because its bounded error was already reported to the caller. If wait() and stop() race, a confirmed Stop outcome replaces the stale in-flight wait cache. The original wait caller still observes its own Promise, while every later wait receives the confirmed terminal result instead of an unknown-handle transport artifact. If the root process exits without an explicit stop() but inherited output pipes remain open, the host owns a separate bounded drain phase. It terminates the original process group, escalates after two seconds, and allows five seconds for stdout/stderr to close. A pipe still held by an escaped descendant is then closed at the host read end and becomes one terminal process error; next(), wait(), and the run() convenience therefore cannot hang forever after the root has already exited. An explicit stop() cancels that automatic pipe-abandonment path and retains the stronger retryable-owner contract above. Node and Desktop preserve the same terminal process error through stop() and the following wait() instead of treating a failed process as successful. Process acquisition is asynchronous on every target so the same source contract works for an in-process Node host and a capability-isolated Desktop host. Process option records are snapshotted only from enumerable own data fields; accessors, symbols, inherited state, and unknown fields are rejected before a child can start. Arguments and explicit environment data each have a 1 MiB aggregate text ceiling in addition to their item-count limits. The Node and Desktop targets initialize one shared process host ABI before accepting application or bridge values. It captures the validation, reflection, Map iteration, Promise, timer, and immutable-result operations used by this contract, while both targets compose the compiler-owned captured UTF-8 sizing runtime. Replacing those JavaScript globals or prototypes after module initialization therefore cannot change process validation or result assembly. Node child-process events and the Desktop worker transport remain target-owned implementation boundaries rather than public VelarScript values. The Node target initializes a dedicated process Worker before the official module becomes available. That Worker loads only compiler-owned source and Node built-ins, so application dependencies never share the Realm in which child_process, streams, decoders, or Buffers execute. Its captured MessagePort proxy is referenced only while a request is pending or a child is running: importing velar/process alone does not keep a CLI alive, while an unobserved active child still owns its lifecycle until it settles. At most 128 unreleased process handles may exist; callers release a settled handle through wait() or stop(). A spawn the operating system refuses is an application failure, not a host failure. A missing executable, a target that is not executable, a target reached through something that is not a directory, and a cwd that does not exist all settle that one call with an error naming the executable and the errno family — ENOENT, EACCES, or ENOTDIR — and leave velar/process working: the next run() or start() behaves as though the refused one had never happened, and a try: / catch failure: around the call is the only place it lands. Permanent poisoning below is the answer for the Worker itself failing, which is a different event.

timeout: 0 means no timeout, exactly as it does in velar/http. The default is 120,000 milliseconds; the accepted range is 0 through 600,000.

The Worker transfers each successful child handle and PID to its captured application-side proxy before resolving start(). If the Worker hits an uncaught internal failure, it stops accepting requests, force-drains every owned child through the still-live ChildProcess handles, and exits only after they settle or the fatal-drain deadline expires. The proxy records the first host failure permanently, rejects both pending and later calls with that same failure, and performs a bounded process-group kill fallback for already transferred owners. It never posts a retry to a dead MessagePort. The private filesystem/serve/HTTP Worker and terminal Worker use the same permanent fail-closed rule. A clean or non-zero unexpected Worker exit is a host failure; terminal failure is not disguised as ordinary end-of-input. These Workers are not restarted inside the current application process: old process, server, request, and terminal handles have no safe identity in a fresh Worker generation. Restarting the application is the explicit authority and identity reset. All three Worker proxies capture the Worker and MessagePort operations they need when they are initialized, so replacing one of those prototype methods afterwards changes nothing they do. A replacement installed before the module is loaded — which is how a profiler or tracing agent patches worker_threads — is captured along with everything real, and a captured operation that refuses is then treated as any dead host operation is: a named module failure that rejects every call, stops the thread, and lets the process exit rather than leaving it running with nothing left to do. A replacement that answers without doing the work it stands for is indistinguishable from the operation itself, and a released reference that was never released can still leave a finished program unable to exit. Replacing MessagePort.prototype.ref or unref before loading is refused earlier still, inside Node's own worker pipeline while the Worker is being constructed; the report is Node's rather than VelarScript's, and the process exits rather than hanging. None of this is a supported way to instrument a program. Desktop navigation is a narrower boundary than application restart. Every main-document bridge instance generates an unguessable private generation and combines it with its page-local request number. The native shell translates that pair to a host-global Worker request identity, so a response from a destroyed document cannot settle a request whose counter restarted after reload. Navigation retirement discards old responses and transfers no Process or HTTP handles: the capability Worker stops the old document's processes, aborts its HTTP bodies, and rejects cross-generation handle use. A filesystem operation already committed by the operating system is not rolled back, but its obsolete result is never delivered into the new document. The completion hooks exposed for native response injection also require the private generation, preventing application code from completing an arbitrary pending bridge Promise by calling the hook directly. The page bridge and native request ledger each enforce a 128 MiB aggregate budget across pending serialized requests; response chunk assembly has the same aggregate ceiling in addition to its per-response bound. A finite bridge timeout sends a private cancellation for the exact generation/request pair before rejecting the page Promise. Native code retains the Worker identity until its terminal response, so a late response is discarded without becoming an unknown-protocol failure. The Worker tracks active dispatches: cancellation aborts an in-flight HTTP request and stops a hidden process owned by run() or an as-yet-unpublished start(). Filesystem syscalls are not falsely described as cancellable; their eventual result only releases the retained capacity and is discarded. Closing the host input applies the same cancellation path before any request that was awaiting process launch can publish a new child. Standard output and error share a bounded capture budget, and no command-string parsing or shell expansion is performed. Timeouts and output-bound failures request termination and independently bound confirmation at five seconds even if the operating system never emits exit or close; an unconfirmed handle is retained rather than misreported as released. A started process controls its ordinary descendant process group by default. A deliberately detached process can escape that group, so an executable grant is not an OS sandbox; retained ownership and bounded host-pipe closure make that limitation explicit instead of silently losing the lifecycle.

Node velar/http

Local programs use the same http.request/get/post/put/patch/delete/head vocabulary as Web code. Requests expose response, json, text, bytes, streamText(consume), parse(Type), and cancel; responses expose checked metadata and the same body readers including parse(Type). Node's response carries the same members Web's does except the browser-only blob() and, like Web's, has no ok: a non-2xx status throws HttpResponseError from response() before the value exists. Typed parsing uses the compiler-known runtime Type registry and returns Promise<T> without a second schema system. A result Type with a callable top-level then is rejected statically because native Promise resolution would assimilate it. Options include headers, body, timeout, and maxBytes. secretHeaders accepts up to 16 descriptors created by secretHeader(name, environment, prefix=""). The descriptor contains only the environment-variable name; the official Node runtime or Desktop capability host resolves the value when the request starts and never returns it to application code. The Node value is sent only across the private host transport. Creating the lazy request validates and snapshots descriptors but does not read their environment variables. A rotation made before the first response/body reader is therefore observed, while a missing value rejects that first effect before any network operation begins. The Web module captures its in-process HTTP host operations when it initializes. Node does not treat a captured Fetch or Headers wrapper as isolation, because Node's transport can consult public application-Realm prototypes internally. Its application-facing module captures validation, collection, URL, timer, and result operations, while the private velar/node-host-v1 dependency owns node:http, node:https, redirects, sockets, response streams, fatal incremental UTF-8 decoding, and cancellation in an isolated Worker. Desktop performs the corresponding privileged effects in its capability worker.

Buffered response readers have one cross-target lifecycle: concurrent text(), json(), and parse(Type) calls share the same pending body read and successful text is cached for later readers. Web blob() shares its byte cache with those readers. streamText() remains deliberately exclusive while it is active, because replaying or duplicating an incremental stream would hide buffering and backpressure. Before the first buffered or streaming body read, all targets reject and cancel a present body whose valid Content-Length already exceeds maxBytes; the running byte bound still protects absent or incorrect declarations. A HEAD or other bodyless response does not fail merely because its representation metadata carries a larger length. Decimal length parsing uses compiler-captured transport intrinsics rather than mutable application regex, number, or string methods; Node inbound request preflight uses the same parser before ServeRequest starts accumulating a body.

import {http, secretHeader} from "velar/http"

const request = http.post("https://api.example.com/v1/run", {
    headers: Map([["content-type", "application/json"]]),
    secretHeaders: [secretHeader("authorization", "PROVIDER_API_KEY", prefix="Bearer ")],
    body: {input: "hello"},
})

Secret header names cannot claim transport-controlled or ambient credential headers, cannot conflict with ordinary headers, and their combined header budget remains 64 KiB. Node and Desktop both follow at most 20 redirects and remove every secret-derived header when a redirect crosses origins. Only HTTP(S) URLs without embedded credentials are accepted. Header, request, and response sizes are bounded, JSON stays on the lossless VelarScript data boundary, and cancellation or timeout remains active until a streamed body has finished rather than ending when response headers arrive. Desktop additionally rechecks the manifest's exact-origin grant before every hop, so a redirect cannot escape the declared network capability. Node and Desktop validate and normalize the method and absolute URL when the request object is created; the Desktop worker independently repeats that validation before any network effect. Every target defaults HTTP timeout to 120,000 milliseconds, accepts only integer values from 0 through 600,000, and reserves 0 as the explicit no-timeout mode. Cancellation clears the owned deadline immediately, while every success or failure path finalizes it before propagating a result; an aborted request cannot leave a process alive through a forgotten default timer.

HTTP failures have three stable categories on every target. A non-2xx response throws HttpResponseError; explicit cancellation or deadline expiry throws HttpAbortError; DNS, connection, socket, or native body-stream failure throws HttpTransportError. Its phase is the typed HttpTransportPhase.request or HttpTransportPhase.response value. A response phase failure may occur after streamText has already delivered text, so a caller must not blindly replay it. Protocol validation, malformed UTF-8, body bounds, and consumer callback failures keep their own errors and are never misclassified as network transport. Retry count, delay, status classification, idempotency, and whether already-visible output may be replayed remain application/provider policy rather than hidden behavior in velar/http.

Node and Desktop also share the exact strict JSON validator used by Web. Request option records are snapshotted from enumerable data descriptors before any network effect; accessors and unknown fields are rejected without being invoked. Non-text bodies are validated and serialized before dispatch, so Map, Set, class values, sparse Lists, cycles, non-finite numbers, and oversized data cannot be silently converted by host JSON.stringify. Response json() reads reject the same lossy values, including a JSON exponent that JavaScript would otherwise parse as Infinity. Desktop sends the already validated body text across its bridge, and the isolated worker refuses structural bodies or unknown wire options instead of becoming a second permissive serializer.

Across Web, Node, and Desktop, the 16 MiB request-body ceiling measures encoded UTF-8 transport bytes rather than JavaScript code units. The compiler owns one platform-neutral byte counter, so multibyte text, surrogate pairs, and unpaired surrogates cannot receive different budgets from TextEncoder and Buffer. JSON bodies are serialized and snapshotted when the lazy request is created; later mutation cannot change what is dispatched. An automatically generated content-type: application/json header is counted inside the same combined 100-field/64-KiB header ceiling, never appended after validation.

The compiler-owned JSON runtime captures the host parser and serializer when a standard module initializes. velar/json, Web/Node/Desktop HTTP, Node serve, browser storage, and IndexedDB all call those same captured intrinsics and the same strict snapshot. Later JavaScript mutation of ambient JSON.parse or JSON.stringify therefore cannot silently change official-module semantics. Browser storage and IndexedDB additionally accept a caller-selected positive UTF-8 byte budget up to 16 MiB. Oversized reads return the typed fallback before JSON parsing, while oversized writes fail before the host mutation or database transaction; applications can therefore enforce a smaller durable-state budget without reimplementing serialization or encoding rules.

Response metadata is also snapshotted once at the host boundary. Node accepts only native Response, Headers, byte-stream, and byte-chunk values; Desktop validates the worker's exact response and chunk records again in the renderer. Every target requires an integer status from 100 through 599 and requires ok to equal the 200-through-299 classification. Status zero and contradictory metadata are rejected at the host boundary before HttpResponseError construction or body processing. Headers are limited to 100 fields and 64 KiB, status text to 64 KiB, and the response URL to 2 MiB. Every target uses that final response URL for a non-2xx HttpResponseError, so diagnostics identify the endpoint that actually failed after redirects. Only a synthetic host response with an empty URL falls back to the initial request URL. In addition to maxBytes, all targets stop after at most 1,000,000 source chunks, so an infinite stream of empty chunks cannot keep a request alive without consuming its byte budget. A response with no body releases its Node or Desktop request lifecycle as soon as metadata arrives. All text and JSON readers, including Web text() after a buffered bytes() read, require valid UTF-8; malformed bytes are never repaired with replacement characters, and no byte that decodes is removed. A leading U+FEFF is text like any other character: text() returns it, bytes() and text() describe the same content, and json() therefore refuses a BOM-prefixed body for the same reason Json.parse does — a byte-order mark is not JSON. Metadata rejection, malformed chunks, byte/chunk overflow, decoder failure, transport failure, cancellation, and timeout all release or cancel the owned response stream. Request header names must be HTTP tokens and values must be single-line text on every target, with validation occurring before browser or host fetch.

velar/env

get(name) -> string? reads one explicit environment variable. require(name) -> string throws a VelarScript error naming an absent variable. Names use the portable [A-Za-z_][A-Za-z0-9_]* shape and at most 256 characters; there is no process-wide environment dump. A variable that is set to the empty string is present: get returns "" and require returns it without complaint. Only an absent name is missing. .env files are not loaded — the environment the process was started with is the whole input — and every read is text: there is no typed or defaulted form here, so a number or a flag is parsed by the application, or declared in @velarscript/server's configuration file instead. On Node, the official module captures the original process.env object and its name-validation/descriptor operations when the module initializes. Replacing the global environment object or RegExp/Object/Reflect operations afterwards cannot redirect a read, and accessor-backed environment values are rejected without execution.

In Desktop, the manifest must grant readable values as individual uppercase environment names. Host-only secrets are a separate permission list and cannot overlap the readable list. A secret may be consumed only through a velar/http.secretHeader descriptor; velar/env and the WebView bridge never receive its value. The native host captures only those values once at application startup, with at most 64 variables, 64 KiB per value, and 1 MiB across names plus values. The renderer independently snapshots only enumerable own string data fields without invoking getters. Desktop home, app-data, and project APIs accept only host-returned absolute paths of at most 4,096 code units. Desktop filesystem calls are confined to declared app-data or project roots, process launches to exact executable names, and HTTP to exact HTTPS or loopback origins. A process-only application does not need to grant velar/fs: its omitted cwd is the application launch directory. An explicit Desktop process cwd must be an existing directory inside a granted file root, and Desktop does not allow an option map to replace PATH.

An executable grant is authority to run that native program and its descendant tree as the current operating-system user. It prevents shell parsing and accidental command substitution; it is not an operating-system sandbox for the program's own filesystem or network effects. Approval policy, argument policy, and stronger sandboxing for untrusted tools belong to the consuming product, not to @velarscript/desktop or the language standard library.

Desktop HTTP bodies are pulled across the bridge in bounded chunks rather than buffered into one transport message. The versioned native bridge also chunks and reassembles large requests and results under explicit aggregate bounds, so the documented 16 MiB filesystem, process-input, and HTTP request contracts do not silently collapse to a smaller transport limit.

velar/host

exit(code=0) accepts integer exit codes from 0 through 255. onShutdown(cleanup) registers an async () -> Promise<null> cleanup for SIGINT/SIGTERM, with at most 1,024 registered callbacks. Cleanups run in registration order under one 30-second graceful-shutdown deadline. Successful shutdown exits with conventional status 130 or 143; a rejection, invalid result, or expired deadline is reported and selects exit 1 instead of leaving the process alive forever. A second signal force-quits immediately, and exits with status 1: a shutdown cut short is a shutdown that did not complete, so it reports as a failed one rather than as 130 or 143.

exit and onShutdown are the whole module. There is no platform name, CPU count, hostname, uptime, or user: the Standard API deliberately publishes no host values, because a value read from the host is a portability decision the application should make in its own words. Signal registration, exit, clocks, timers, Promise observation, cleanup-list mutation, and synchronous diagnostics are captured during module initialization. Async cleanup results are observed through the captured native Promise operation rather than assimilated through a later replacement of Promise.prototype.then.

velar/terminal

import {terminal} from "velar/terminal"

await terminal.write(terminal.args().join(" ") + "\n")
const answer = await terminal.readLine("Continue? [y/N] ")
await terminal.writeError(answer == "y" ? "continuing\n" : "stopped\n")
terminal.close()

terminal.args() returns a fresh List<string> containing only program arguments after velar run ... --. isInteractive() reports whether both input and output are attached to a terminal. readLine(prompt="") resolves to one line without its newline, or null at EOF. write and writeError await stdout/stderr backpressure instead of silently accumulating streamed output. Each argument list, line, prompt, write, and queued-input window has an explicit bound. Input that arrives between readLine calls is paused and delivered through the next Promise; an oversized line rejects that Promise rather than escaping from a Node event callback. close() is permanent and idempotent even before the first read, so a later readLine() returns null instead of opening stdin again. Output closes with it: write and writeError after close() throw Error("Terminal is closed"), so a CLI that closes stdin after its last prompt must print everything it still owes before closing. args() and isInteractive() keep answering. Node streams, readline events, raw-mode state, and process globals are not part of the language API. Node does not implement this contract by constructing readline in the application Realm. An eagerly initialized compiler-owned Worker owns stdin decoding and fd 1/2 writes in an isolated Realm, while a captured MessagePort proxy revalidates every line, completion, and error. On POSIX, stdin is read through an owned duplicate descriptor so a pending read can be cancelled without closing the process-wide fd 0. Idle imports are unreferenced; pending operations retain the process, and close() waits for the host to release its read before terminating the Worker. The Worker's stdin stream itself is initialized only by the first readLine; importing velar/terminal or writing output does not leave an open reader that can keep a CLI alive.

velar/test

velar/test provides the small assertion surface used by velar test without introducing a second testing language.

import {expect} from "velar/test"

test "a profile keeps its declared name and tags":
    const profile = {name: "Ada", tags: ["compiler", "web"]}
    expect(profile.name).toBe("Ada")
    expect(profile.tags).toContain("web")
    expect(profile).toEqual({name: "Ada", tags: ["compiler", "web"]})
  • The two comparison matchers do the language's two comparison jobs, and each is the operation it names rather than a second implementation of it. An assertion that disagreed with the language's own equality would be the worst kind of trap, so neither one restates a comparison.
    • toBe is value equality: the same SameValueZero comparison a == b lowers to. With const missing = 0 / 0, expect(missing).toBe(missing) passes for exactly the reason missing == missing is true, and 0 and -0 compare equal.
    • toEqual is content equality: the prelude equals(a, b), the same implementation and not a matching one. It therefore inherits everything equals does, including structural Set members and Map keys, NaN equal to itself, and a refusal — rather than a quiet false — for cyclic or over-deep data.
  • toBeTruthy and toBeFalsy require actual true and false, rather than JavaScript truthiness. toContain accepts text or a dense List, and toHaveLength accepts text or a dense List.
  • toMatch accepts a Unicode string pattern of at most 4,096 code units. It uses the native regular-expression intrinsic captured by the module and does not trust a replaced global RegExp constructor. An invalid toMatch pattern throws TypeError carrying the engine's reason, the same way Text.matches does; toMatch compiles in Unicode mode too.
  • toThrow requires a synchronous function. toReject requires an actual Promise or a function returning one; arbitrary thenables are rejected without reading a then accessor.
  • Matchers and failure display capture their collection, text, JSON quoting, numeric, RegExp, Promise, reflection, and Error operations when velar/test initializes. Replacing JavaScript globals or prototypes later cannot change a pass/fail result or redirect diagnostic formatting.
  • Failure display is deliberately bounded to 1,000 visited nodes, 16 levels, 50 collection items, and 256 string code units. It reads only accepted dense List and data-record fields; assertion diagnostics never become an unbounded object inspector.

Deliberate omissions

Standard API 0.5 deliberately keeps shells, sockets below the checked server abstraction, filesystem streams, recursive deletion, byte inspection, reflection, pickle, dynamic import machinery, and JavaScript's legacy prototype surface out of Core. Process execution and filesystem mutation live in explicit Node or Desktop target extensions rather than ambient Core globals. Browser capabilities remain in independently versioned Web modules. Canvas and game development remain a later velar/game package built on the Web platform, not part of the language runtime.