Load this after velar skill core. This brief contains only the Node owner’s
contract. Core does not learn server declarations, path-pattern strings, Node
capabilities, or application configuration.
@velarscript/node is a capability extension. It owns route syntax,
path-pattern strings, Node modules, HTTP/WebSocket transport, and low-level
server lifecycle; it does not own convention-based application configuration.
An ordinary service activates @velarscript/server and then loads
velar skill server for manifest-declared application configuration, assembly, and
connection lifecycle.
A deliberately low-level Node application may activate Node directly. Its
selected entry source owns startup inside @main, exactly like Core, Web, and
Desktop entries:
{
"formatVersion": 2,
"entry": "src/main.vel",
"outDir": "dist",
"publicDir": "public",
"build": {"mode": "production", "sourceMaps": false},
"extensions": ["@velarscript/node"]
}The low-level entry owns its own startup arguments in code:
import {run, serve} from "velar/serve"
import {app} from "./app.vel"
@main:
const server = await serve(app, port=3000, host="127.0.0.1")
await run(server)
For one shared HTTP/WebSocket application port, use the WebSocket transport's matching lifecycle operation:
import {app as routes} from "./app.vel"
import {listen, run} from "velar/websocket"
@main:
const server = await listen({
host: "127.0.0.1",
port: 3000,
http: routes,
origins: ["https://app.example.com"],
maxBodyBytes: 16777216,
})
await run(server)
run(server) owns SIGINT/SIGTERM shutdown for either transport. The external
CLI executes the compiled entry module directly.
Use direct Node activation for tools, embedded adapters, and tests. For an
application service, activate @velarscript/server; it provides the same
velar dev, velar serve, and standalone velar build workflow without a
second runtime configuration source.
@ remains the language-wide marker introducer, and @name is a context
marker with a compiler-owned compile-time role. In a server block, the
available markers are @get, @post, @put, @patch, @delete,
@websocket, @notFound, and @response. They are not decorators, functions, imports, user-defined
annotations, first-class values, or user extension points.
A server is an immutable anonymous route table:
import {HttpProblem, created} from "velar/serve"
type CreateArticle:
title: string
export server articles:
/// Reports whether this service is ready.
@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):
return created({id: 1, title: input.title})
p"..." is a first-class RoutePattern. An inline pattern projects its typed
path captures and query fields directly as immutable handler locals. A pattern
stored elsewhere must use an explicit binding such as @get(articlePath as route); its RouteMatch contains pattern, pathname, params, and query,
and str(route) or str(route.pattern) returns the complete declaration. Query shorthand
?{details:bool?} uses the field name on the
wire; ?details={details:bool?} spells it explicitly, and
?include-details={details:bool?} maps a different wire name. The trailing
? makes a query field optional; without it the framework rejects a missing
value before the handler runs. Path fields are always required. Captures use
an ASCII half-width : and accept string, number, bool, or a named enum.
The explicit same-name form remains valid but reports advisory A11; its
mechanical fix removes the redundant details= prefix. Different-name aliases
do not report it.
Route catalogs may keep patterns in exported const objects. Referenced
patterns require as name so the compiler never injects identifiers that are
hidden inside another declaration. The old path= spelling is an error with a
mechanical fix to the positional form and as path.
For a public contract, put one stable source identifier between the role and
the pattern: @get readArticle(...) or @websocket worldRealtime(...).
Operation identities must be unique after composition, survive prefix, and
are emitted verbatim to OpenAPI. Leave the name out only for a local route whose
method/path-derived OpenAPI identity is sufficient.
On POST, PUT, or PATCH, one concrete Data parameter is the
checked JSON body. A Request parameter explicitly requests the complete
request, including queryAll and cooperative cancellation. Ambiguous bodies,
duplicate declarations, conflicting path shapes, and unsupported path types are
compile errors.
@notFound() is the one application fallback for a path that matches no
route. It may omit its parameter or accept one explicitly typed Request.
Returning Data keeps status 404; return an explicit response such as
json(value, status=410) to choose another final status. A matched route's
HttpProblem and framework 405 responses do not enter this fallback. Declare at
most one on the final application; an app that owns @notFound cannot be moved
under a non-root prefix, because a global fallback has no unambiguous prefix
scope. Compose prefixed route tables first, then declare the fallback on the
outer server. A catch-all route such as staticFiles("/", ...) is a matched
route and therefore owns its own file fallback instead of entering
@notFound.
Routes return semantic values by default. Plain Data becomes a negotiated 200
response; created(value), respond(value, status), and noContent() choose
status without choosing a wire encoder. HttpProblem({...}) is the checked
failure contract; its semantic code is reason, and code on it is the Error
contract's own member — the class name "HttpProblem" — so a read of .code
is refused and names reason. The framework renders unhandled problems as
application/problem+json with type, title, status, and a stable code
field carrying that reason, and performs Accept negotiation before writing a
response. Every
framework refusal a request can reach — 400 for a path that is not a path, 405,
413, 415, 422, and a static route's 404 and 416 — is that same problem document,
matching what openapi() publishes; only a request line or header block that
never became a request answers with one line of text/plain.
The final application may declare one response policy when it needs a shared envelope or representation:
import {HttpOutcome, Request, json} from "velar/serve"
server api:
@response(outcome: HttpOutcome, request: Request):
if outcome.problem != null:
return json(
{ok: false, error: outcome.problem.reason, requestPath: request.path},
status=outcome.status,
headers=outcome.problem.headers,
)
return json(
{ok: true, data: outcome.value},
status=outcome.status,
headers=outcome.headers,
)
The policy receives every semantic result, including framework validation,
404, and 405 outcomes. It returns Data or one explicit final response and runs
exactly once. json, text, redirect, file, stream, and sse remain
escape hatches that already choose their final representation and status. When
a policy uses one of them only to select an encoder, it must pass through
outcome.status and the applicable semantic headers as shown above. A response
policy is global and therefore cannot be placed under prefix; compose it on
the final application.
Use ordinary values for the cases that need more than inference:
import {input, provide, security} from "velar/serve"
type User:
id: string
type UploadMetadata:
title: string
const currentUser = provide(
inputs={token: security.bearer()},
resolve=async values => {id: values.token},
)
server account:
@get(p"/me",
user=input.dependency(currentUser),
tenant=input.header("x-tenant"),
session=input.cookie("session", default=null),
) => {id: user.id, tenant, session}
@post(p"/images",
metadata=input.form(UploadMetadata),
image=input.upload("image", maxBytes=8_388_608),
) => {title: metadata.title, filename: image.filename}
input.header and input.cookie select named scalar values. Query values live
in the p"...?" contract; Request.query/queryAll remain the raw escape hatch.
input.form(Type) checks URL-encoded or multipart fields, including
repeated fields for List<scalar> properties; duplicate scalar fields fail.
input.upload returns an Upload whose bytes are valid only for the request
lifetime; copy or persist them before retaining data. security.apiKey,
basic, bearer, oauth2, and openId parse credentials and also
feed OpenAPI security schemes. They do not verify a password, token signature,
issuer, audience, session, or user record. An application using the explicit
Server extension composes a descriptor with its installed verifier through
authenticate; low-level Node code may perform the same policy in an ordinary
request Provider.
provide(inputs, resolve, scope="request", release=null, eager=false) declares
a dependency as data. A request-scoped provider resolves once per request even
when injected repeatedly and releases after response/background completion.
An app-scoped provider may depend only on other app-scoped providers, may be
eager, and releases during shutdown. Cycles and provider-budget exhaustion fail
closed. Do not build a controller or container layer around it.
When the process has already constructed an application resource, bind it with
supply(app, provider, value). The provider must be app-scoped, the value is
checked against that provider's inferred result type, and its release callback
still owns shutdown. The binding follows prefix, middleware, docs, lifecycle,
and server composition; supplying the same provider twice is rejected.
Compose route tables as values:
import {Request, RouteDocumentation, docs, lifecycle, middleware, prefix, staticFiles, use} from "velar/serve"
const service = lifecycle(
prefix("/api", articles),
startup=async () => null,
shutdown=async () => null,
)
const hardened = use(service, [
middleware.trustedHosts(["api.example.com", "127.0.0.1"]),
middleware.cors(origins=["https://app.example.com"], credentials=true),
middleware.requestId(),
middleware.securityHeaders(),
middleware.compression(minimumBytes=1024),
middleware.timeout(10_000),
middleware.concurrency(256),
])
const articleDocs: RouteDocumentation = {summary: "Read article", tags: ["articles"], errors: Map([[404, "Article not found"]])}
const routeDocs: Map<string, RouteDocumentation> = Map([["GET /api/articles/{id:number}", articleDocs]])
export server app:
...docs(hardened, title="Article API", version="1.0.0", routes=routeDocs)
...staticFiles("/assets", root="public")
@notFound(request: Request) => {error: "route_not_found", path: request.path}
prefix changes a literal route prefix; bodyLimit narrows one route
table’s body budget; use attaches middleware only to the routes it receives —
and to that application's own 404 for a path no route claims.
A middleware continuation is single-use. middleware.errors(handle) is offered
the error a route ended with, wherever it sits in the chain. Built-ins also include access logging
and explicit error recovery. A timeout response does not pretend downstream
work was cancelled: its request resources remain alive until that work really
ends, and the runtime caps unfinished timed-out tasks.
Overlapping routes are refused by two referees. At compile time the analyzer
follows the path-preserving combinators: use, bodyLimit, docs, and
lifecycle carry their app argument’s routes through unchanged, and prefix
translates them when its path is a string literal. So
...prefix("/api", routes) is checked against the composing server’s own
routes at the translated addresses, and the diagnostic names that address and
the server the route was composed in from. A module-level const alias of a
server resolves, and so does a let the module never reassigns. A spread the
analyzer cannot resolve, such as an imported server, a computed prefix path, or
an app handed back by a function, is deliberately let through; a false conflict
here would block a correct program. The final route table exists only at
assembly, so the runtime judges it there: an overlapping table refuses to build,
and the failure names both routes, where each one came from, and the method and
path shape they share. A WebSocket upgrade is a GET on the wire, so a @get
route and a @websocket route at one path are refused by the same referee that
judges two HTTP routes — the answer openapi() has always given. A declarative
@websocket route owns its own path, so listen({http: app, path: ...}) is
refused as well, naming the path it refused. Both referees read one definition of that shape, and a
program the compiler rejected never reaches assembly, so nothing is reported
twice.
lifecycle owns paired startup and shutdown hooks. Successful startups unwind
in reverse order; a failed startup does not run its paired shutdown.
background(response, task) keeps request-scoped resources valid until the
task finishes. setCookie and
clearCookie add checked cookie headers, preserving separate Set-Cookie
fields. Never start long-lived work by
dropping a Promise; use an owned lifecycle, background task, Worker, or process.
docs adds a bundled offline UI and OpenAPI 3.1 JSON. HTTP and WebSocket
operations share its paths catalog; WebSocket entries use GET, response 101,
and x-velar-transport: websocket. Compiler route types
and response helpers supply parameter, body, response, content-type, and static
success-status schemas. Applicable framework-generated 400, 401, 413, 415, and
422 responses are included automatically; a preceding /// comment supplies
the route description. The optional ordinary typed
Map<string, RouteDocumentation> can add
summary, description, tags, success status, documented error statuses, or hide
a route, keyed by strings such as "GET /articles/{id:number}". This metadata
is data, not another @ category.
Ordinary Data returns through content negotiation. Use respond, created,
and noContent to select semantic status; use the explicit transport helpers
when representation matters: json, redirect, text,
file, stream, sse, background, setCookie, and
clearCookie. HttpProblem(options) is the expected HTTP failure. The default
problem representation follows application/problem+json. Unexpected failures
are reported on stderr and become an opaque 500. A client that hangs up before
its response completes is not a failure: it is its own stderr line, Client closed the connection before the response completed <method> <path>.
Returning null returns Data — a 200 whose body is JSON null. noContent()
is the way to say there is nothing to send. stream sends exactly the headers
you give it, so set content-type yourself; a producer that fails after its
first chunk ends the connection with a FIN and no terminating chunk, which is
what tells the client the body is incomplete.
Every ending leaves through the route's middleware: a thrown HttpProblem, a
framework 405 or 422, and the opaque 500 all carry the security, CORS, and
request-id headers a 200 carries, and so does the framework's own 404 when the
application has no @notFound.
HEAD reuses GET without a body. OPTIONS and 405 responses publish a
complete Allow header. Final response statuses are 200 through 599; 204 and
304 are bodyless. Streaming follows write backpressure; each chunk and the
total stream are bounded. SSE accepts text or checked
{data, event?, id?, retry?} events. Static and returned files are canonical
root-contained, streamed reads with validators and one byte range. A relative
root — staticFiles("/assets", root="public"), file(path, root="dist") —
resolves against the project root the build knew, the directory holding
velar.json, so the same build serves the same files from any working
directory and velar run, which compiles into <project>/.velar/, serves what
a directory build does; an output that does not sit inside that project falls
back to the emitted entry's own directory, and an absolute root is used as
given. Upload.save(path, root) writes through that same rule.
velar/websocket.listen({http: app, ...}) serves a ServeApp and WebSocket
upgrades on one native server and owns the application lifecycle. A declarative
session belongs in the route table:
import {WebSocketConnection} from "velar/websocket"
server realtime:
@websocket worldRealtime(p"/worlds/{worldId:string}/realtime", connection: WebSocketConnection):
async for message in connection:
await connection.send(message)
Matching, path/query decoding, dependencies, security, headers, cookies, and
the optional Request are resolved before upgrade. Exactly one
WebSocketConnection is supplied by the framework, the handler must finish
with null, and its lifetime is joined with shutdown. Inline patterns project
captures directly; referenced patterns require as route. A listener whose app
contains these routes rejects its legacy single path option and does not offer
accepted sessions through WebSocketServer.next().
Set
maxBodyBytes to the supplied application value. host is non-empty text of
at most 255 code units without NUL. origins contains exact
canonical HTTP/HTTPS origins. The default rejects any upgrade carrying
Origin; no-Origin non-browser clients remain allowed. Use ["*"] only for an
intentional unrestricted policy. A rejected browser origin receives 403 before
it consumes connection-queue capacity. Connections are pull-based. Always
consume each connection's next(), handle backpressure, and stop the server.
closeInfo() waits for the terminal handshake and returns the actual typed
WebSocketClose {code, reason}; next() returning null alone does not expose
why the connection ended. Application sessions should normally use
server-target velar/realtime.realtimeSession instead of rebuilding their own writer and
cleanup loops over this transport.
Use velar/server-test only from *.test.vel. It runs the real ServeApp
router, providers, lifecycle, cookies, forms, uploads, streams, and files in
process without opening a port:
import {client} from "velar/server-test"
import {expect} from "velar/test"
import {app} from "./app.vel"
test "health endpoint":
const api = await client(app)
try:
const response = await api.get("/api/health")
expect(response.status).toBe(200)
expect(await response.text()).toContain("ok")
finally: await api.close()
Pass a Map<Provider, value> as the second client argument to override
dependencies. Request options support headers, JSON, text, form Maps, and file
Maps. Test WebSocket behavior through a real local velar/websocket server,
because WebSocket upgrades are a transport boundary rather than an in-process
HTTP request.
The runtime has hard ceilings; application code must preserve them:
- Request bodies are at most 16 MiB, with narrower per-route and project limits. HTTP request bodies, buffered responses, static files, and stream chunks also share one 128 MiB isolated-host budget.
- Live inbound HTTP requests and middleware concurrency are capped at 4,096. ServeApp routes, request providers, app providers, and timed-out unfinished work have separate count limits.
- WebSocket queued bytes and pending-send bytes share a 128 MiB budget. Global limits also cover active connections, pending connection objects, queued message objects, and pending send Promises, including zero-byte messages.
- Multipart parsing reuses bounded body storage; an
Uploadis invalidated when its request scope ends. Provider caches and release lists are explicitly cleared at request or application shutdown. - Development rebuilds serialize changes, retain one last-good child, stop the old child before replacing it, and delete temporary build sandboxes.
Set smaller limits for the domain instead of treating framework maxima as defaults. Stream large data, consume pull queues promptly, close every owned capability, and keep caches explicitly bounded.
Concurrency limits are admission control, not a lock around application data.
The runtime guarantees that an application-scoped provider initializes once,
concurrent stop or close calls join the same completion, shutdown refuses
new work, requests receive cooperative cancellation, and provider release begins
only after every admitted request has finished its response, timed-out
continuation, background work, and request cleanup. stop(grace=30000) bounds
the drain wait without releasing live request resources early. WebSocket stop
closes connections in parallel with a bounded handshake and releases unread
queues and pending-send reservations.
Application code still owns business-level consistency. Do not use an
unprotected module-level mutable record as a cross-request database. Put
multi-write invariants in a database transaction or another capability that
provides atomic operations. Always await stream writes, allow only one active
pull from an async iterator, and make retryable handlers idempotent. A
middleware.concurrency limit protects capacity; it does not make a shared
read-modify-write sequence atomic.
Use Core velar/hash for deterministic digests. Use velar/fs, velar/path,
velar/process, velar/env, velar/terminal, velar/http, velar/worker, and velar/websocket instead of
ambient Node globals. Database contracts, drivers, codecs, and other
application integrations are project-owned modules or dependencies. Declare
third-party boundaries with checked extern module; keep import js unsafe
at one narrow validation boundary.
Four things about those capabilities that a program only discovers at run time
otherwise. A spawn the operating system refuses — a missing or non-executable
command, or a cwd that does not exist — fails that one call, naming the
executable and ENOENT/EACCES/ENOTDIR, and leaves velar/process working;
timeout: 0 there means no timeout, as it does in velar/http. After
terminal.close(), write and writeError throw, so print what you owe before
closing. velar/host is exit and onShutdown and nothing else — no platform
name, CPU count, or hostname — and a second signal force-quits with exit status
1, the status of a shutdown that did not finish. velar/env reads untyped text:
a variable set to "" is present, not missing, and .env files are not loaded.
Text readers keep a leading U+FEFF, so sha256Text(await readText(path)) is the
digest of the file and a BOM-prefixed JSON body is refused as the malformed JSON
it is.
An HTTP response has no ok field on any target: response() throws
HttpResponseError for every non-2xx status before the value exists, so every
response the code can hold already succeeded. Wrap the call in try: /
catch failure:, narrow with if failure is HttpResponseError: —
HttpResponseError is imported from velar/http — and read failure.status
there.
Wire-shaped values arrive as unknown, not as a checked shape. An
HttpOutcome’s value, the payload of a response whose declared type is
ServeResponse, the path items of an openapi(...) document, the error handed
to a middleware.errors handler, and a test response’s parsed body are all
unknown: validate one with Type.parse, or narrow it, before touching a
member. A middleware reaches no payload at all: await next() answers the whole
ServeResponse union, whose three variants share no payload field and no
discriminant, so status and headers are the members it can read.
request.parse(Report) is unchanged and stays the checked way in. Constructing
responses is unchanged too; json and created accept any payload and hand
back a response whose payload keeps the type it was given, and that payload
turns into a read-only unknown once the value is widened to ServeResponse,
which is what lets a response carrying a concrete payload still be a
ServeResponse.
Run velar format --check, velar check, velar test, and
velar build. The complete runnable spelling lives in
examples/tour/node/; current diagnostics outrank this brief.