Skip to content

Latest commit

 

History

History
528 lines (449 loc) · 25.2 KB

ai-skill-node.md

File metadata and controls

528 lines (449 loc) · 25.2 KB

The VelarScript Node AI skill brief

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.

Ownership and low-level application entry

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

Routes and checked inputs

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.

Composition, lifecycle, and middleware

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.

Responses and realtime

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.

Tests

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.

Memory and ownership rules

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 Upload is 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 and shutdown rules

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.

Node capabilities and finish

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.