Zokor documentation
API reference
Browse public Zokor types, functions, and methods, with signatures and descriptions generated from the source package.
The public package surface contains 589 entries across 38 source files. This page is generated from docs/api.txt, which is generated from zokor/.
auth.sl#
Authentication helpers: bearer JWTs, Basic credentials and API keys.
gc struct JwtAuth { keys: [bytes], audience: str, issuer: str, leeway: int }jwt_auth(secrets: [str]) -> result[JwtAuth, str]Secrets of at least 32 bytes each, newest first.
jwt_auth_from(cfg: Config) -> JwtAuthJWT_SECRETS (comma-separated, newest first), JWT_AUDIENCE, JWT_ISSUER and JWT_LEEWAY (seconds, default 30).
sign_token[T](claims: T, a: JwtAuth) -> result[str, str]A token for claims, signed with the newest key.
token_problem(token: str, a: JwtAuth, now: int) -> strWhy token is not acceptable at now, or "" when it is.
bearer_claims[S, T](c: Ctx[S], a: JwtAuth) -> result[T, http.Response]The token's claims as your own type, once the token has passed every check; otherwise the 401 to send.
gc struct BasicCredentials { user: str, password: str }basic_credentials[S](c: Ctx[S]) -> opt[BasicCredentials]The credentials of an Authorization: Basic header, or none when there is no such header or it does not decode to...
basic_matches(got: BasicCredentials, user: str, password: str) -> boolWhether got is user / password.
basic_denied[S](c: Ctx[S], realm: str) -> http.ResponseThe 401 that asks a browser for Basic credentials for realm.
gc struct ApiKeys { names: [str], digests: [bytes] }api_keys(entries: [str]) -> result[ApiKeys, str]name:key entries; each key at least 24 bytes.
api_keys_from(cfg: Config) -> ApiKeysAPI_KEYS: comma-separated name:key entries.
api_key_client[S](c: Ctx[S], keys: ApiKeys) -> opt[str]The name of the client whose key is in the X-Api-Key header, or none.
client.sl#
The client's address: who is actually on the other end of a request.
gc struct TrustedProxies { nets: [int], header: str }no_trusted_proxies() -> TrustedProxiesparse_ipv4(s: str) -> int"a.b.c.d" as an unsigned 32-bit value, or -1.
parse_trusted_proxies(list: str) -> result[TrustedProxies, str]TRUSTED_PROXIES's value: comma-separated IPv4 addresses and CIDRs ("10.0.0.0/8, 192.168.1.10").
ipv4_str(v: int) -> strTrustedProxies.trusts(ip: int) -> boolTrustedProxies.is_empty() -> boolpeer_ip_of(rendered: str) -> strThe address part of inspect(link.peer()), "a.b.c.d:port" -> "a.b.c.d".
client_ip_of(peer_ip: str, headers: bytes, tp: TrustedProxies) -> strWho the request came from.
compression.sl#
Response compression: gzip through slang's compress (zlib), for clients that accept it, on bodies worth it.
gc struct Compression { min_size: int, level: int, types: [str] }compression() -> Compressionaccepts_gzip(header: str) -> boolWhether an Accept-Encoding value allows gzip.
conditional.sl#
Conditional requests (RFC 9110 section 13): answer "not modified" when the client already has the current version, and refuse...
etag_of(data: bytes) -> strA strong entity tag for data: its length and 64-bit FNV-1a hash, quoted.
with_validators(resp: http.Response, etag: str, last_modified: opt[timez.Time]) -> http.Responseresp with ETag (when etag is not "") and Last-Modified (when known, to the second: an HTTP date has no fraction).
not_modified(etag: str, last_modified: opt[timez.Time]) -> http.ResponseThe 304 for a read whose validators matched: no body, and the validators the 200 would have carried.
preconditions[S](c: Ctx[S], etag: str, last_modified: opt[timez.Time]) -> opt[http.Response]What a conditional request's headers decide, before the handler does its work: some(304) or some(412) to send as is, none to...
config.sl#
Configuration: read once, validated once, then held as a value.
gc struct Problem { key: str, reason: str, shown: str }gc struct Config { values: map[str]str, problems: [Problem], source: str, profile: str, sources: [str] }gc struct ConfigLoad { profile: str, directory: str, max_file_bytes: int }Profile selection and file limits, chosen before startup loading.
config_load() -> ConfigLoadconfig_from_values(values: map[str]str) -> ConfigOwn a copy of supplied values, with no process-state reads: useful for fakes.
default_env_path() -> str { return ".env"; } // Base .env, then .env.<profile>, then one environment snapshot. pub fn load_config() -> Config { return load_config_with(config_load()); } // Explicit dev/test/prod overrides ZOKOR_PROFILE for file selection. pub fn load_config_profile(profile: str) -> Configload_config_with(options: ConfigLoad) -> Configload_config_from(path: str) -> Config { return load_config_file(path, config_load().max_file_bytes); } pub fn load_config_file(path: str, max_file_bytes: int) -> ConfigLoad one explicit file, then apply the captured environment; no profile overlay.
get(c: Config, key: str) -> opt[str]has_key(c: Config, key: str) -> boolstr_or(c: Config, key: str, fallback: str) -> strint_or(c: Config, key: str, fallback: int) -> intbool_or(c: Config, key: str, fallback: bool) -> boolduration_or(c: Config, key: str, fallback_ns: int) -> intA duration in nanoseconds, the unit every slang timeout takes.
require(c: Config, key: str) -> strrequire_int(c: Config, key: str) -> intrequire_range(c: Config, key: str, lo: int, hi: int) -> intrequire_port(c: Config, key: str) -> intrequire_bool(c: Config, key: str) -> boolrequire_float(c: Config, key: str) -> floatrequire_email(c: Config, key: str) -> strrequire_url(c: Config, key: str) -> strrequire_dsn(c: Config, key: str) -> strrequire_host(c: Config, key: str) -> strrequire_ipv4(c: Config, key: str) -> strrequire_uuid(c: Config, key: str) -> strrequire_one_of(c: Config, key: str, allowed: [str]) -> strrequire_duration(c: Config, key: str) -> intrequire_min_len(c: Config, key: str, n: int) -> strrequire_max_len(c: Config, key: str, n: int) -> stroptional_one_of(c: Config, key: str, allowed: [str], fallback: str) -> strAn optional value that still has to be valid WHEN it is set: the common case of a feature that is off until configured.
optional_url(c: Config, key: str, fallback: str) -> stroptional_dsn(c: Config, key: str, fallback: str) -> strSame shape as optional_url, for a connection string rather than an HTTP one: is_dsn accepts any scheme with a host...
problems(c: Config) -> [Problem]is_valid(c: Config) -> boolcheck(c: Config) -> result[bool, str]Every problem, one per line, named and explained.
cookies.sl#
Cookies.
enum SameSite { LAX, STRICT, NONE }gc struct Cookie { name: str, value: str, path: str, domain: str, max_age: int, expires: opt[timez.Time], secure: bool, http_only: bool, same_site: SameSite, partitioned: bool }new_cookie(name: str, value: str) -> Cookiecookie_problem(c: Cookie) -> strWhat is wrong with c, or "".
cookie_string(c: Cookie) -> strThe Set-Cookie value for c, unchecked: callers check first.
set_cookie(resp: http.Response, c: Cookie) -> result[http.Response, str]resp with a Set-Cookie header for c, or why c cannot be sent.
clear_cookie(resp: http.Response, name: str, path: str, domain: str) -> result[http.Response, str]Tells the browser to delete name.
parse_cookies(header: str) -> map[str]strEvery well-formed name=value pair in a Cookie header, in order; a pair without =, with a bad name or with bytes a cookie...
gc struct CookieKeys { keys: [bytes] }cookie_keys(keys: [str]) -> result[CookieKeys, str]Keys of at least 32 bytes each, newest first.
cookie_keys_from(cfg: Config) -> CookieKeysCOOKIE_KEYS, comma-separated, newest first.
constant_time_equal(a: bytes, b: bytes) -> boolCompares in time that depends only on the lengths, so a forged signature cannot be found a byte at a time by timing the answer.
signed_cookie(name: str, value: str, keys: CookieKeys) -> result[Cookie, str]A cookie whose value is value.signature.
signed_value[S](c: Ctx[S], name: str, keys: CookieKeys) -> opt[str]The value of signed cookie name when its signature matches any key, none when it is missing, unsigned or forged.
verify_signed(name: str, raw: str, keys: CookieKeys) -> opt[str]cors.sl#
CORS.
gc struct CorsPolicy { origins: [str], methods: [str], headers: [str], expose: [str], credentials: bool, max_age: int }no_cors() -> CorsPolicyOff: no origins.
cors_policy(origins: [str]) -> CorsPolicyThe defaults, for these origins.
cors_problem(p: CorsPolicy) -> strWhat is wrong with p, or "" when it is usable.
cors_from(cfg: Config) -> CorsPolicyCORS_ORIGINS (comma-separated), CORS_METHODS, CORS_HEADERS, CORS_EXPOSE (comma-separated, replacing the defaults),...
gc struct Cors { on: bool, any: bool, credentials: bool, origins: [str], allow_methods: str, allow_headers: str, allow_headers_lower: [str], expose: str, max_age: str }What the router keeps: the policy, and the header values built once.
cors_off() -> CorsCors.allows(origin: str) -> boolWhether origin (an Origin header's value) is allowed.
Cors.apply(resp: http.Response, origin: str) -> http.ResponseThe headers for an ordinary response to a request with this Origin ("" when it had none).
Cors.preflight(origin: str) -> http.ResponseThe answer to a preflight from origin.
is_preflight(method: str, raw_headers: bytes) -> boolWhether a request is a CORS preflight: OPTIONS, with an Origin and the method the real request will use.
errors.sl#
Failure, as one shape.
gc struct Code { name: str, status: i32, message: str }gc struct FieldError { field: str, reason: str }gc struct ErrorView { code: str, message: str, status: i32, request_id: str, fields: [FieldError], trace_id: str }Everything the renderer is given.
gc struct Registry { codes: map[str]Code, render: fn(ErrorView) -> http.Response, trace_id: str }new_registry() -> RegistryEvery HTTP failure status, under the name a handler will reach for.
set_renderer(r: Registry, f: fn(ErrorView) -> http.Response) -> intReplace the shape every failure is rendered in.
register(r: Registry, name: str, status: i32, message: str) -> intlookup(r: Registry, name: str) -> opt[Code]status_of(r: Registry, name: str) -> i32A code nobody registered is a bug in the service, not a client error: it answers 500 and says which name was missing, so the...
message_of(r: Registry, name: str) -> strcode_names(r: Registry) -> [str]respond(r: Registry, code: str, request_id: str) -> http.ResponseThe registered wording.
respond_with(r: Registry, code: str, detail: str, request_id: str) -> http.ResponseThe registered code, with wording for this one occurrence ("no such organisation 'acme'").
respond_fields(r: Registry, code: str, fields: [FieldError], request_id: str) -> http.Responsefield(name: str, reason: str) -> FieldErrordefault_render(v: ErrorView) -> http.Responsezokor's own shape, and the one a service gets until it says otherwise.
quote(s: str) -> strA JSON string, escaped.
quote_into(sb: builder.Str, s: str) -> builder.StrThe same, appended to a builder.
status_text(status: i32) -> strfiles.sl#
Static files: a directory served under a path prefix, safely.
gc struct FileMount { prefix: str, root: str, index: str, max_age: int, immutable: bool, max_size: int }files_at(prefix: str, dir: str) -> result[FileMount, str]A mount of dir under prefix.
content_type_for(name: str) -> strparse_range(header: str, size: int) -> [int]One byte range of a file of size: [first, last] inclusive; [-1, -1] when the header is not one usable range (several,...
health.sl#
Health and readiness.
gc struct ReadyCheck[S] { name: str, check: fn(S) -> result[int, str], lock: mutex, started: int, finished: int, passed: bool, reported: str }gc struct Readiness[S] { live_path: str, ready_path: str, checks: [ReadyCheck[S]], timeout: int, logger: Logger }default_ready_timeout() -> int0.9s: inside Kubernetes' default 1s probe timeout, so a slow dependency is answered as a 503 naming it rather than as a probe...
check_name_ok(name: str) -> boolA check name is 1-64 of [A-Za-z0-9_.-]: it is written into the JSON body and to stderr as is.
idempotency.sl#
Idempotency keys (the IETF Idempotency-Key header): a client retrying a POST or PATCH after a timeout gets the first answer...
gc struct Idempotency { ttl: int, lock_ttl: int, max_body: int, scope_local: str, required: bool, shards: [IdemShard], remote: opt[redis.Pool] }idempotency_in_memory() -> IdempotencyAnswers kept in this process: right for one instance.
idempotency_redis(p: redis.Pool) -> IdempotencyAnswers kept in Redis, shared by every instance behind the balancer.
ids.sl#
Identifiers.
new_request_id() -> str128 bits of randomness, hex, prefixed so it is recognisable in a log that carries other people's ids too.
new_session_id() -> strA longer one, for a session or a socket.io sid, where a collision matters more than the length does.
request_id_for(headers: bytes) -> strThe id a request is answered and logged under: the client's own X-Request-Id when it is 1-64 characters of [A-Za-z0-9._-], so...
jobs.sl#
Background jobs: work on a schedule, and work queued to run later.
gc struct JobCtx[S] { state: S, name: str, payload: str, attempt: int, due: timez.Time, deadline: until }What a run gets: the service's state, which run this is, and the deadline to pass to everything that can block.
gc struct Jobs[S] { state: S, zone: timez.Zone, clock: timez.Clock, logger: Logger, default_timeout: int, scheduled: [ScheduledJob[S]], kinds: [JobKind[S]], queue: [QueuedJob], lock: mutex, in_flight: int, stopping: bool, failures: int }new_jobs[S](state: S) -> Jobs[S]job_backoff(attempt: int) -> intThe wait before try attempt + 1: 1 s, 2 s, 4 s ...
Jobs.every(name: str, spec: str, run: fn(JobCtx[S]) -> result[int, str], timeout: int) -> result[int, str]Runs run on the cron schedule spec (see timez.parse_cron), each run given timeout nanoseconds as its deadline.
Jobs.handle(kind: str, run: fn(JobCtx[S]) -> result[int, str], attempts: int) -> result[int, str]Registers what runs a queued job of kind, and how many tries it gets (at least 1).
Jobs.enqueue(kind: str, payload: str, at: timez.Time) -> result[int, str]Queues one run of kind with payload, due at at (now or in the past runs at the next tick).
Jobs.queued() -> intQueued runs not yet started.
Jobs.running() -> intRuns started and not yet finished.
Jobs.tick(now: timez.Time) -> intStarts every run due at now -- scheduled turns and queued jobs -- each in its own task, and returns how many it started.
Jobs.start() -> intStarts the scheduler: a task that ticks at each due time, and at least every quarter second so a stop or SIGTERM is seen quickly.
Jobs.stop() -> intStops starting runs.
Jobs.wait_runs(timeout: int) -> boolWaits until no run is in progress, or timeout nanoseconds pass; true when none is.
json.sl#
JSON, for the shapes you know and the ones you do not.
enum JsonKind { Null, Bool, Number, String, Array, Object }gc struct Json { kind: JsonKind, bool_val: bool, raw: str, text: str, items: [Json], keys: [str], values: [Json] }jnull() -> Jsonjbool(v: bool) -> Jsonjint(v: int) -> Jsonjfloat(v: float) -> Jsonjstr(v: str) -> Jsonjobj() -> Jsonjarr() -> Jsonuser_json(id: str) -> bytesuser_json_bytes(id: bytes) -> bytesuser_json_bytes: the id as BYTES (already sliced out of a path).
message_json(message: str) -> bytesJson.set(key: str, v: Json) -> JsonSets a key, replacing it if present, and returns the object so calls chain.
Json.set_str(key: str, v: str) -> JsonJson.set_int(key: str, v: int) -> JsonJson.set_float(key: str, v: float) -> JsonJson.set_bool(key: str, v: bool) -> JsonJson.set_opt_str(key: str, v: opt[str]) -> JsonOnly when the value is there: the difference between "absent" and "null" belongs to the caller, not to the encoder.
Json.add(v: Json) -> JsonJson.add_str(v: str) -> JsonJson.add_int(v: int) -> JsonJson.get(key: str) -> JsonA missing key, or an index that is not there, gives Null rather than an error -- so a chain of lookups never has to be guarded...
Json.at(i: int) -> JsonJson.path(p: str) -> Json"user.address.city", "items.0.name": one call instead of five.
Json.has_key(key: str) -> boolPresent, even if its value is null -- which is how a PATCH tells "set this to nothing" from "leave it alone".
Json.is_null() -> boolJson.is_object() -> boolJson.is_array() -> boolJson.is_string() -> boolJson.is_number() -> boolJson.is_bool() -> boolJson.size() -> intItems in an array, keys in an object, bytes in a string.
Json.keys_of() -> [str]Json.items_of() -> [Json]Json.str_or(fallback: str) -> strJson.int_or(fallback: int) -> intJson.float_or(fallback: float) -> floatJson.bool_or(fallback: bool) -> boolJson.render() -> strThe JSON text of this value.
Json.render_bytes() -> bytesRenders straight into a Bytes builder: no two-element parts list, no join, no str in between.
parse(text: str) -> result[Json, str]snake_keys(text: str) -> strslang has no struct tags, so a wire name is a field name.
camel_keys(text: str) -> strto_snake_case(s: str) -> strto_camel(s: str) -> strparse_body(content_type: str, body: bytes) -> result[Json, FormError]The body as a value you can walk, with the failure already carrying a registered code.
decode_failed(reg: Registry, why: str, request_id: str) -> http.ResponseThe failure of slang's own json.decode, mapped into the envelope with the offending field named where the message carries...
gc struct Checker { fields: [FieldError] }Decoding says a field is a string; it does not say the string is an email, or between 3 and 40 characters, or one of four words.
checker() -> CheckerChecker.failed() -> boolChecker.note(field: str, reason: str) -> intChecker.req_str(j: Json, key: str, min: int, max: int) -> strA required string, with a length range.
Checker.opt_str(j: Json, key: str, max: int, fallback: str) -> strChecker.req_int(j: Json, key: str, min: int, max: int) -> intChecker.opt_int(j: Json, key: str, min: int, max: int, fallback: int) -> intChecker.req_bool(j: Json, key: str) -> boolChecker.req_email(j: Json, key: str) -> strChecker.req_url(j: Json, key: str) -> strChecker.req_uuid(j: Json, key: str) -> strChecker.req_one_of(j: Json, key: str, allowed: [str]) -> strChecker.req_strs(j: Json, key: str, max_items: int) -> [str]A list of strings, with a cap on how many: the shape of "tags", "recipients", "ids".
Checker.req_obj(j: Json, key: str) -> JsonA nested object, so a checker can walk into it and report "address.city" rather than "address".
Checker.under(prefix: str, other: Checker) -> intEverything found so far, re-reported under a prefix: for a nested object checked by its own function.
log.sl#
Logging.
enum LogLevel { DEBUG, LOG, WARN, ERROR, OFF }level_rank(l: LogLevel) -> intlevel_name(l: LogLevel) -> strparse_level(s: str) -> opt[LogLevel]gc struct LogEvent { time: int, level: LogLevel, context: str, message: str, request: bool, method: str, route: str, path: str, status: int, duration_ns: int, size: int, request_id: str, client_ip: str, trace_id: str, ack: opt[chan[int]] }gc struct Logger { events: opt[chan[LogEvent]], level: LogLevel, requests: bool, console: bool, json: bool, color: bool, capacity: int, lock: mutex, dropped: int, no_ack: opt[chan[int]] }default_log_capacity() -> intHow many events may wait for the writer: enough to absorb a burst of a few thousand requests between two wake-ups.
no_logger() -> LoggerLogs nothing.
console_logger(json: bool, color: bool, level: LogLevel, requests: bool) -> Loggerzokor's own writer, to stdout, configured but not started: start (which listen_and_serve_with calls) makes the channel and...
channel_logger(ch: chan[LogEvent], level: LogLevel, requests: bool) -> LoggerA logger whose events go to ch, drained by the caller's own task.
default_logger() -> LoggerThe default: pretty and coloured on a terminal, JSON otherwise, level LOG, request lines on.
logger_from(cfg: Config) -> LoggerLOG_FORMAT, LOG_LEVEL, LOG_REQUESTS and NO_COLOR over the default.
Logger.start()Starts the console writer if this is an unstarted console logger; otherwise does nothing.
Logger.own_tasks() -> intTasks this logger runs for itself: 1 once the console writer is started.
Logger.enabled(l: LogLevel) -> boolWhether an event at l would be sent at all: lets a caller skip building one.
Logger.emit(ev: LogEvent)Offers ev without waiting: dropped and counted when the channel is full or below the level.
Logger.record_request(method: str, route: str, raw_path: str, status: int, duration_ns: int, size: int, request_id: str, client_ip: str)The serve loop's call: one request's record, if requests are logged at its level.
Logger.debug(context: str, message: str)Logger.log(context: str, message: str)Logger.warn(context: str, message: str)Logger.error(context: str, message: str)Logger.take_dropped() -> intEvents dropped and not yet reported; resets the count.
Logger.flush(timeout_ns: int)Waits until everything emitted before this call is written, or timeout_ns passes.
request_event(method: str, route: str, raw_path: str, status: int, duration_ns: int, size: int, request_id: str, client_ip: str) -> LogEventA request's record.
logs_requests(lg: Logger, status: int) -> boolWhether the serve loop should build a request record at all.
safe_path(raw: str) -> strA client-supplied path made safe to print: the query dropped, at most 64 bytes kept, and every byte outside printable ASCII --...
utc_parts(unix_ns: int) -> [int]Unix nanoseconds as UTC civil time: [year, month, day, hour, minute, second, millisecond].
gc struct LogFormat { pid: str, three: [str], plain: [str], sec: int, stamp_t: str, stamp_sp: str, quoted: map[str]str }Formatting state, made once and reused for every line: tables of the numbers a line needs (so a status, a size or a...
new_log_format(pid: int) -> LogFormatLogFormat.stamp(unix_ns: int, t: bool) -> str"2026-10-12T18:47:01.123Z" (t) or "2026-10-12 18:47:01.123Z".
LogFormat.json_into(sb: builder.Str, ev: LogEvent) -> builder.StrOne JSON object and a newline, appended to sb.
LogFormat.pretty_into(sb: builder.Str, ev: LogEvent, color: bool, since_ns: int) -> builder.StrThe NestJS layout and a newline, appended to sb: [zokor] <pid> - <utc time> LOG [Context] message +3.21ms The...
LogFormat.json(ev: LogEvent) -> strThe same as single strings, without the newline: for a consumer that wants one line at a time.
LogFormat.pretty(ev: LogEvent, color: bool, since_ns: int) -> strmetrics.sl#
Opt-in Prometheus metrics with bounded request series and custom scalars.
gc struct MetricsConfig { path: str, max_series: int, max_custom: int, buckets_ns: [int] }metrics_config() -> MetricsConfigenum MetricKind { Counter, Gauge }gc struct Metric { name: str, help: str, kind: MetricKind, lock: mutex, value: int }gc struct Metrics { path: str, max_series: int, max_custom: int, bounds: [int], lock: mutex, active: int, dropped: int, routes: map[str]int, series_index: map[int]int, series: [RequestSeries], custom: [Metric] }new_metrics(cfg: MetricsConfig) -> result[Metrics, str]Copies configuration so later caller mutations cannot change bucket shapes.
Metrics.enter()Metrics.leave(method: str, route: str, status: int, ns: int)One completion for each enter, including a panic or failed socket write.
Metrics.counter(name: str, help: str) -> result[Metric, str]Metrics.gauge(name: str, help: str) -> result[Metric, str]Metric.add(delta: int) -> result[bool, str]Counters only increase; gauges can add a signed delta.
Metric.set(value: int) -> result[bool, str]Metric.read() -> intmetrics_text(m: Metrics) -> strA consistent snapshot is copied under the lock; text formatting happens after unlocking so scrapes do not hold up request...
migrations.sl#
Forward-only SQL migrations: immutable history and an atomic pending batch.
gc struct Migration { version: int, name: str, sql: str, checksum: str }gc struct MigrationLimits { max_files: int, max_file_bytes: int, max_total_bytes: int }Limits for reading files, chosen by the caller before startup.
migration_limits() -> MigrationLimitsmigration_checksum(sql: str) -> strnew_migration(version: int, name: str, sql: str) -> Migrationload_migrations(dir: str, limits: MigrationLimits) -> result[[Migration], str]Load N_description.sql files, sort numerically, and reject duplicates.
migrate(p: pg.Pool, ms: [Migration], deadline: until) -> result[int, str]Validate all historical files, then apply the pending batch in one transaction under a database-wide advisory lock.
negotiation.sl#
Startup-compiled representations and bounded RFC 9110 Accept negotiation.
gc struct AcceptLimits { max_bytes: int, max_ranges: int, max_params: int, max_offers: int, max_offer_bytes: int }accept_limits() -> AcceptLimitsgc struct Negotiator { offers: [str], types: [str], subtypes: [str], params: [[str]], max_bytes: int, max_ranges: int, max_params: int }Treat the compiled vectors as immutable after startup.
new_negotiator(offers: [str], limits: AcceptLimits) -> result[Negotiator, str]Offer order breaks otherwise equal preferences.
Negotiator.choose(header: opt[str]) -> result[opt[str], str]none means no acceptable offer; err means malformed Accept.
negotiate[S](c: Ctx[S], n: Negotiator) -> result[str, http.Response]A selected media type, or a registered 400/406 response.
openapi.sl#
Startup OpenAPI generation from registered routes and explicit wire schemas.
gc struct OpenApiLimits { max_routes: int, max_schemas: int, max_schema_bytes: int, max_total_schema_bytes: int, max_schema_depth: int, max_document_bytes: int, max_collection_entries: int, max_metadata_bytes: int }Resource limits for startup metadata and the generated document.
openapi_limits() -> OpenApiLimitsgc struct ApiParameter { name: str, location: str, schema: str, required: bool, description: str }schema is a registered DTO name or a primitive JSON Schema type.
gc struct ApiResponse { status: i32, description: str, schema: str, media_type: str }An empty schema declares a response without a body.
gc struct ApiOperation { method: Method, pattern: str, operation_id: str, summary: str, description: str, tags: [str], parameters: [ApiParameter], request_schema: str, request_media_type: str, request_required: bool, responses: [ApiResponse], errors: [str] }Metadata cannot create routes: method/pattern must occur in routes_list.
gc struct OpenApi { title: str, version: str, description: str, server_urls: [str], schemas: map[str]str, operations: [ApiOperation], omitted: [str], error_schema: str, error_media_type: str, limits: OpenApiLimits }Configure before generation.
new_openapi(title: str, version: str) -> OpenApiapi_operation(method: Method, pattern: str) -> ApiOperationapi_parameter(name: str, location: str, schema: str) -> ApiParameterapi_response(status: i32, schema: str) -> ApiResponseOpenApi.schema(name: str, definition: str) -> intA JSON Schema definition, parsed within limits at generation.
OpenApi.operation(op: ApiOperation) -> intOpenApi.omit(method: Method, pattern: str) -> intExplicitly exclude an unsupported or private route from this document.
api_default_error_schema() -> strEmbed this definition when the application's renderer uses the default envelope.
api_ref(name: str) -> strA self-contained reference, or an inline primitive schema.
openapi_document(routes: [str], reg: Registry, api: OpenApi) -> result[str, str]Generate at startup; returns every detected metadata problem together.
pagination.sl#
Pagination: offset pages and keyset (cursor) pages, one envelope for both, so a client reads every list the same way:
struct Page { limit: int, offset: int, cursor: str, by_cursor: bool }What the request asked for, checked.
Page.fetch() -> intHow many rows to ask the database for: one more than the page, so the response knows whether there is a next page without a count.
page_of[S](c: Ctx[S], default_limit: int, max_limit: int) -> result[Page, http.Response]The page c asks for: limit (default default_limit, at most max_limit), and either offset or cursor.
encode_cursor(parts: [str]) -> strThe opaque cursor for a row whose sort key is parts.
decode_cursor(cursor: str) -> result[[str], str]The key parts of a cursor, err when it is not one encode_cursor made.
cursor_parts(p: Page) -> [str]The key parts of the cursor p came with ([] on the first page).
offset_page[S, T](c: Ctx[S], p: Page, rows: [T], total: opt[int]) -> http.ResponseAn offset page of rows -- up to p.fetch() of them; one past the limit means there is a next page -- in the envelope, with Link.
cursor_page[S, T](c: Ctx[S], p: Page, rows: [T], key: fn(T) -> [str]) -> http.ResponseA keyset page of rows -- up to p.fetch(); one past the limit means there is a next page, whose cursor is key of the last...
postgres.sl#
Postgres, wired to zokor's config, results and error envelope.
pg_from_config(cfg: Config) -> result[pg.Pool, str]DATABASE_URL (postgres://user:pass@host:port/db?sslmode=require) and DB_POOL_SIZE (default 10).
has_postgres(cfg: Config) -> boolpg_close(p: pg.Pool)row_json(rows: pg.Rows, r: int) -> strRow r as a JSON object keyed by column name.
rows_as[T](rows: pg.Rows) -> result[[T], str]Every row as a T, matched by column name without intermediate JSON text.
query_all[T](p: pg.Pool, sql: str, args: [pg.Arg], deadline: until) -> result[[T], str]Runs sql with args on a pooled connection and returns every row as a T.
query_one[T](p: pg.Pool, sql: str, args: [pg.Arg], deadline: until) -> result[opt[T], str]The first row as a T, none when the query returned no row.
conn_query_all[T](c: pg.Conn, sql: str, args: [pg.Arg], deadline: until) -> result[[T], str]The same on a connection you hold -- inside a transaction body.
transaction[A, R](p: pg.Pool, arg: A, body: fn(pg.Conn, A) -> result[R, str], deadline: until) -> result[R, str]Runs body(conn, arg) inside BEGIN ...
is_unique_violation(e: str) -> booldb_failure(reg: Registry, e: str, request_id: str) -> http.ResponseA database error as the answer to send, in the service's envelope.
ratelimit.sl#
Rate limiting.
gc struct RateShard { hits: map[str][int], lock: mutex, since_sweep: int }gc struct RateLimiter { limit: int, window: int, shards: [RateShard], sweep_every: int }new_rate_limiter(limit: int, window_ns: int) -> RateLimiterAt most limit requests per client in any window_ns nanoseconds (1000000000 is one second).
retry_after_secs(wait_ns: int) -> intWhole seconds until wait_ns has passed, never below 1: a Retry-After of 0 invites an immediate retry that will be refused.
struct RateCheck { wait: int, remaining: int, reset: int }One check's answer, for the RateLimit-* headers.
RateLimiter.take_at(key: str, now: int) -> intCounts one request for key at now (time.mono nanoseconds).
RateLimiter.check_at(key: str, now: int) -> RateChecktake_at, with what is left: what the RateLimit-* headers report.
RateLimiter.take(key: str) -> intThe same, now.
RateLimiter.tracked() -> intHow many client keys are being tracked.
rate_limit[S](c: Ctx[S], l: RateLimiter, key: str) -> opt[http.Response]The body of a rate-limiting before: none to continue, or the registered rate_limited failure (429 unless re-registered)...
rate_limit_client[S](c: Ctx[S], l: RateLimiter) -> opt[http.Response]rate_limit keyed on the client's address: the socket's peer, or, behind a proxy listed in TRUSTED_PROXIES, the address that...
redirect.sl#
Checked redirects for variable destinations; redirect remains the raw constructor.
gc struct RedirectRules { max_location_bytes: int, allow_external: bool }redirect_rules() -> RedirectRulesRelative references by default.
redirect_checked(status: i32, location: str, rules: RedirectRules) -> result[http.Response, str]Validate status and destination together without including destination secrets in errors.
redirect_to(location: str) -> result[http.Response, str]A checked 303 for the usual POST/redirect/GET flow, using relative references.
redis.sl#
Redis, wired to zokor's config system.
redis_from_config(cfg: Config) -> result[redis.Pool, str]REDIS_URL, read the same way every other optional dependency in this package is: config.optional_dsn, so a malformed value (a...
has_redis(cfg: Config) -> booltrue iff REDIS_URL is set -- for a service that wants to know whether to wire Redis-backed features in at all before trying to...
redis_close(p: redis.Pool)Closes every idle connection and marks the pool closed; an in-flight acquire finishes normally, and the next one fails with...
respond.sl#
Success responses.
json_response(status: i32, body: str) -> http.Responsejson_response_bytes(status: i32, body: bytes) -> http.Responsejson_response with a BYTES body: same shape, no to_bytes copy.
ok_json(body: str) -> http.Responseok is a slang builtin (the result constructor), so the 200 helper is named for what it sends.
ok_json_bytes(body: bytes) -> http.Responseok_json with a BYTES body: the render_bytes path -- no str, no to_bytes, no copy between the renderer and the socket.
created(body: str, location: str) -> http.Responsecreated_bytes(body: bytes, location: str) -> http.Responseaccepted(body: str) -> http.Responseaccepted_bytes(body: bytes) -> http.Responseaccepted with a BYTES body: same shape, no to_bytes copy.
no_content() -> http.Responsetext(status: i32, body: str) -> http.Responsetext_bytes(status: i32, body: bytes) -> http.Responsetext with a BYTES body: same shape, no to_bytes copy.
html(status: i32, body: str) -> http.Responsehtml_bytes(status: i32, body: bytes) -> http.Responsehtml with a BYTES body: same shape, no to_bytes copy.
bytes_of(status: i32, content_type: str, body: bytes) -> http.Responseredirect(status: i32, location: str) -> http.Response303 after a POST, 307/308 to preserve the method, 301/302 for the older shapes.
with_header(r: http.Response, name: str, value: str) -> http.Responseno_store(r: http.Response) -> http.ResponseCache-Control in the two forms a service actually needs.
cache_for(r: http.Response, seconds: int) -> http.Responserouter.sl#
The router: a request goes in, a response comes out, and the application's own state comes with it.
enum Method { GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE, CONNECT }The HTTP methods, as a closed set.
gc struct Ctx[S] { state: S, req: http.Request, params: opt[map[str]str], param1_name: str, param1_value: str, route: str, request_id: str, client_ip: str, out: opt[[str]], locals: opt[map[str]str], errors: Registry, upgrade: opt[Socket] }struct Served { resp: http.Response, route: str, upgrade: opt[Socket] }A response and the route pattern that produced it, for the request log.
gc struct Route[S] { method: Method, pattern: str, befores: [fn(Ctx[S]) -> opt[http.Response]], afters: [fn(Ctx[S], http.Response) -> http.Response], segs: [str], names: [str], wild: int, fkind: int, fpath: str, handler: fn(Ctx[S]) -> http.Response, has_static: bool, static_status: i32, static_ctype: str, static_body: bytes, static_rendered: bytes, static_hit: opt[http.StaticBody], static_close_hit: opt[http.StaticBody], no_map: opt[map[str]str], ws: int }gc struct Router[S] { routes: [Route[S]], befores: [fn(Ctx[S]) -> opt[http.Response]], afters: [fn(Ctx[S], http.Response) -> http.Response], state: S, errors: Registry, auto_options: bool, auto_head: bool, ready: Readiness[S], cors: Cors, security: Security, sanitize: SanitizeMode, auto_etag: bool, rindex: [RNode], static_paths: map[str]int, idem: opt[Idempotency], gzip: opt[Compression], mounts: [FileMount], no_lines: opt[[str]], observations: opt[Metrics], tracer: opt[Tracer], no_tracer: opt[Tracer], request_locals: opt[map[str]str], sockets: [WsRoute[S]], no_socket: opt[Socket] }A before runs ahead of the handler and may end the request by returning a response; none carries on.
new_router[S](state: S) -> Router[S]An application builds its router from its state alone; everything else starts empty or at the default a correct server should...
new_group[S](r: Router[S], prefix: str) -> Group[S]The function-call equivalent of r.group(prefix), for a service that wants its setup to read as a list of constructors rather...
Router.handle(method: Method, pattern: str, h: fn(Ctx[S]) -> http.Response) -> intEvery method goes through here, including the ones with their own helper below, so a pattern is split exactly once and the...
Router.handle_with(method: Method, pattern: str, h: fn(Ctx[S]) -> http.Response, befores: [fn(Ctx[S]) -> opt[http.Response]], afters: [fn(Ctx[S], http.Response) -> http.Response]) -> intThe one registration path: a pattern is split exactly once, and the rules about :params and *rest live here alone.
Router.static_bytes(p: str, status: i32, content_type: str, body: bytes, h: fn(Ctx[S]) -> http.Response) -> intThe full set of HTTP methods, so no service has to fall back to handle with a string for anything standard.
Router.websocket(p: str, open: fn(Ctx[S]) -> http.Response, w: WsRoute[S]) -> intA WebSocket route (ws_serve.sl).
Router.socketio(p: str, open: fn(Ctx[S]) -> http.Response, s: SioRoute[S]) -> intA Socket.IO route (sio_serve.sl), usually at "/socket.io/".
Router.get(p: str, h: fn(Ctx[S]) -> http.Response) -> intRouter.head(p: str, h: fn(Ctx[S]) -> http.Response) -> intRouter.post(p: str, h: fn(Ctx[S]) -> http.Response) -> intRouter.put(p: str, h: fn(Ctx[S]) -> http.Response) -> intRouter.patch(p: str, h: fn(Ctx[S]) -> http.Response) -> intRouter.delete(p: str, h: fn(Ctx[S]) -> http.Response) -> intRouter.options(p: str, h: fn(Ctx[S]) -> http.Response) -> intRouter.trace(p: str, h: fn(Ctx[S]) -> http.Response) -> intRouter.connect(p: str, h: fn(Ctx[S]) -> http.Response) -> intRouter.any(methods: [Method], p: str, h: fn(Ctx[S]) -> http.Response) -> intOne handler for several methods on one pattern.
Router.group(prefix: str) -> Group[S]A prefix with its own middleware.
Router.guarded(method: Method, pattern: str, h: fn(Ctx[S]) -> http.Response, befores: [fn(Ctx[S]) -> opt[http.Response]]) -> intMiddleware for one route, which is the other half of the same need: `r.guarded(Method.DELETE, "/orgs/:id", drop,...
Router.before(f: fn(Ctx[S]) -> opt[http.Response]) -> intRouter.after(f: fn(Ctx[S], http.Response) -> http.Response) -> intRouter.etags() -> intTags every 200 answer to a GET or HEAD with a strong ETag -- a hash of the body it sends -- and answers a matching...
Router.idempotency(store: Idempotency) -> intAnswers a retried POST or PATCH that carries an Idempotency-Key with the first answer, from store (see idempotency.sl).
Router.compress(z: Compression) -> intGzips answers worth it for clients that accept it (see compression.sl).
Router.files(m: FileMount) -> intServes the directory of m under its prefix (see files.sl): a GET or HEAD no route claims.
Router.security_headers(h: SecurityHeaders) -> intReplaces the security headers (see security.sl); no_security_headers() turns them off.
Router.mongo_sanitize(mode: SanitizeMode) -> intTurns the operator-injection guard on (REJECT, STRIP) or off; see sanitize.sl.
Router.cors(p: CorsPolicy) -> intTurns CORS on with p (see cors.sl).
Router.health() -> intAnswers GET /healthz (liveness) and GET /readyz (the checks below).
Router.health_at(live_path: str, ready_path: str) -> intThe same at paths of the service's choosing.
Router.ready_check(name: str, f: fn(S) -> result[int, str]) -> intRegisters a dependency /readyz waits on.
Router.ready_timeout(ns: int) -> intHow long /readyz waits for its checks, in nanoseconds; below 1ms is raised to 1ms.
Router.routes_list() -> [str]"METHOD /pattern" per route, in registration order: for a boot log, for zokor check, and for a test that pins down the...
Router.metrics(m: Metrics) -> intOpt-in Prometheus endpoint; a service's explicit route still wins.
Router.tracing(driver: Tracer) -> intRouter.allowed(p: str) -> [Method]The methods registered for a path, for Allow on a 405 and for an automatic OPTIONS.
Router.serve(req: http.Request) -> http.ResponseDispatch: befores, the handler, then afters -- and the afters run even when a before ended the request, so a logging or header...
Router.match_static(f: http.WireFrame) -> opt[http.StaticBody]Frame dispatch: route on the ALREADY-PARSED method/path strs, with no raw bytes, no offsets, no segs list, no params map...
Router.serve_static(f: http.WireFrame) -> result[http.StaticBody, http.Response]Router.serve_frame(f: http.WireFrame, request_id: str) -> http.Responseserve() stays for callers that already hold a Request (tests, middleware fixtures); serve_conn uses this via serve_static.
Router.serve_frame_for(f: http.WireFrame, request_id: str, client_ip: str) -> http.Responseserve_frame, with the client's address for Ctx.client_ip.
Router.serve_frame_routed(f: http.WireFrame, request_id: str, client_ip: str) -> ServedWhat the serve loop calls: the response, and the route pattern that answered it for the request log ("" when none did).
Router.serve_id(req: http.Request, request_id: str) -> http.ResponseRouter.serve_id_for(req: http.Request, request_id: str, client_ip: str) -> http.ResponseRouter.serve_id_routed(req: http.Request, request_id: str, client_ip: str) -> ServedRouter.fail(c: Ctx[S], code: str) -> http.ResponseThe registry's codes, rendered with this request's id.
Router.fail_with(c: Ctx[S], code: str, detail: str) -> http.ResponseRouter.fail_fields(c: Ctx[S], code: str, fields: [FieldError]) -> http.Responsejoin_methods(ms: [Method]) -> strgc struct Group[S] { router: Router[S], prefix: str, befores: [fn(Ctx[S]) -> opt[http.Response]], afters: [fn(Ctx[S], http.Response) -> http.Response] }A prefix and the middleware that goes with it.
Group.group(prefix: str) -> Group[S]A group inside this one: the prefixes join and the middleware accumulates.
Group.before(f: fn(Ctx[S]) -> opt[http.Response]) -> intGroup.after(f: fn(Ctx[S], http.Response) -> http.Response) -> intGroup.handle(method: Method, pattern: str, h: fn(Ctx[S]) -> http.Response) -> intGroup.websocket(p: str, open: fn(Ctx[S]) -> http.Response, w: WsRoute[S]) -> intr.websocket, under the group's prefix and with its hooks.
Group.get(p: str, h: fn(Ctx[S]) -> http.Response) -> intGroup.head(p: str, h: fn(Ctx[S]) -> http.Response) -> intGroup.post(p: str, h: fn(Ctx[S]) -> http.Response) -> intGroup.put(p: str, h: fn(Ctx[S]) -> http.Response) -> intGroup.patch(p: str, h: fn(Ctx[S]) -> http.Response) -> intGroup.delete(p: str, h: fn(Ctx[S]) -> http.Response) -> intGroup.options(p: str, h: fn(Ctx[S]) -> http.Response) -> intCtx.trace() -> TraceContextEmpty identifiers when tracing is off.
Ctx.trace_request(req: httpc.Request) -> result[httpc.Request, str]Ctx.logger(log: Logger) -> TraceLoggerCtx.param(name: str) -> strCtx.param_int(name: str, fallback: int) -> intCtx.header(name: str) -> strCtx.add_header(name: str, value: str) -> intAdds a header to whatever response this request ends with: the handler's, or a before's short-circuit, or an error.
Ctx.cookie(name: str) -> strThe value of cookie name, "" when the request carried none.
Ctx.cookies() -> map[str]strEvery cookie the request carried.
Ctx.set_local(key: str, v: str) -> intWhat a before resolved, read by the handler: the user a token belongs to, the tenant a subdomain names, the plan a customer...
Ctx.socket() -> SocketThe socket a WebSocket route's handshake is opening: put it in a hub, set its locals.
Ctx.accept_ws() -> http.ResponseThe 101 that accepts the upgrade.
Ctx.local(key: str) -> strCtx.has_local(key: str) -> boolCtx.body_str() -> strCtx.body_bytes() -> bytesCtx.method() -> MethodThe verb as a checked value.
Ctx.method_str() -> strThe verb exactly as the client sent it, for logs.
Ctx.path() -> strCtx.query(name: str) -> strOne query parameter, percent-decoded, or "".
Ctx.query_int(name: str, fallback: int) -> intCtx.bearer() -> strThe bearer token without its scheme, or "".
Ctx.content_type() -> strCtx.json_body() -> result[Json, FormError]The body as a JSON value, for a shape you have not declared.
Ctx.form(rules: UploadRules) -> result[Form, FormError]The multipart form this request carries, under the caller's rules.
Ctx.upload(rules: UploadRules) -> result[Form, FormError]The same, refused unless the request is a POST/PUT/PATCH: a body on a GET is a client doing something strange.
Ctx.wants_close() -> booldto[S, T](c: Ctx[S]) -> result[T, http.Response]The body decoded into a declared shape, in one call: the wire's camelCase keys become the struct's snake_case fields (slang's...
query_as[S, T](c: Ctx[S]) -> result[T, http.Response]The query string bound into a declared shape, in one call, with the same field-level errors a bad body gets -- let r:...
sanitize.sl#
Operator injection: the NoSQL half of input sanitisation.
enum SanitizeMode { OFF, REJECT, STRIP }sanitize_from(cfg: Config) -> SanitizeModeMONGO_SANITIZE: off (default), reject or strip.
operator_key(k: str) -> boolA key MongoDB would read as an operator or a path.
find_operator_key(j: Json) -> strThe first operator key in j, as a path ("filter.$where", "items[2].$gt"), or "" when there is none.
strip_operator_keys(j: Json) -> intRemoves every operator key in j, at any depth, in place; returns how many were removed.
sanitize_request(req: http.Request, mode: SanitizeMode) -> strWhat the guard says about req under mode: "" to carry on (after stripping in place, in STRIP mode), or the offending key's...
security.sl#
Security headers.
gc struct SecurityHeaders { csp: str, hsts_max_age: int, hsts_subdomains: bool, hsts_preload: bool, nosniff: bool, frame_options: str, referrer_policy: str, coop: str, corp: str }default_security_headers() -> SecurityHeadersno_security_headers() -> SecurityHeaderssecurity_problem(h: SecurityHeaders) -> strWhat is wrong with h, or "".
security_lines(h: SecurityHeaders) -> [str]The header lines, "name: value", in a fixed order.
security_from(cfg: Config) -> SecurityHeadersSECURITY_HEADERS (false turns all of them off), CONTENT_SECURITY_POLICY ("off" for none), HSTS_MAX_AGE (seconds, 0 = off),...
gc struct Security { lines: [str], prefixes: [str] }What the router keeps: the lines, and each line's lowercase name for the "a handler set its own" check.
security_compile(h: SecurityHeaders) -> SecuritySecurity.apply(resp: http.Response) -> http.Responseresp with every line it does not already carry.
static_render_with(status: i32, content_type: str, lines: [str], body: bytes, keep_alive: bool) -> bytesA static route's bytes: status line, content type, the security lines, length, connection, the date line, body.
serve.sl#
The server edge: the accept loop that turns a Router into a running server.
gc struct ServerConfig { idle_timeout: int, header_timeout: int, body_timeout: int, write_timeout: int, max_request_bytes: int, max_requests_per_conn: int, shutdown_timeout: int, request_ids: bool, trusted_proxies: TrustedProxies, logger: Logger, max_connections: int, tls: TlsSettings }Four deadlines, not one, because "how long should this wait" has different honest answers depending on what a connection is...
gc struct TlsSettings { on: bool, cert_file: str, key_file: str, client_ca_file: str, handshake_timeout: int }A certificate chain and its private key, as PEM files, read once when the server starts: a missing or mismatched pair stops it...
no_tls() -> TlsSettingstls_files(cert_file: str, key_file: str) -> TlsSettingsHTTPS from these two PEM files, with every other setting at its default.
default_server_config() -> ServerConfigSafe rather than infinite, per the todo item this answers.
server_config_from(cfg: Config) -> ServerConfigThe same defaults, overridable per field from config: IDLE_TIMEOUT, READ_HEADER_TIMEOUT, READ_BODY_TIMEOUT, WRITE_TIMEOUT,...
serve_conn[S](r: Router[S], c: link, sc: ServerConfig)One connection, until the client goes away or asks to close.
gc struct ConnGate { lock: mutex, open: int, max: int, refused: int, reported_at: int }The server-wide connection count, shared by every acceptor.
new_conn_gate(max: int) -> ConnGateConnGate.enter() -> boolTakes a slot for a new connection; false (and counted as refused) when every slot is taken.
ConnGate.leave()ConnGate.open_now() -> intConnGate.refusals_to_report(now: int) -> intThe refusals to report, at most once per second of now: 0 when there are none or the last report was under a second ago.
serve_tls_conn[S](r: Router[S], c: link, sc: ServerConfig, ctx: rawptr)serve_conn over TLS: the handshake first, under its own deadline, then the same loop.
accept_backoff(prev_ns: int) -> intThe pause after a failed accept: 5ms, doubling to 1s while it keeps failing.
listen_and_serve[S](r: Router[S], port: int) -> result[int, str]Bind, then accept until the process is asked to stop.
listen_and_serve_with[S](r: Router[S], port: int, sc: ServerConfig) -> result[int, str]Same, with timeouts, limits, and the shutdown drain window under the caller's control -- built from config with...
sio_serve.sl#
Socket.IO routes, served: Engine.IO v4 and Socket.IO v5 (what socket.io-client 3 and 4 speak), on both of its transports.
gc struct SioRoute[S] { settings: SioSettings, event: fn(S, Socket, SioEvent), connect: opt[fn(S, Socket, str, str) -> result[bool, str]], disconnect: opt[fn(S, Socket, str, str)], sessions: SioSessions }sio_route[S](event: fn(S, Socket, SioEvent)) -> SioRoute[S]SioRoute.on_connect(f: fn(S, Socket, str, str) -> result[bool, str]) -> SioRoute[S]SioRoute.on_disconnect(f: fn(S, Socket, str, str)) -> SioRoute[S]socket.sl#
A live connection the serve loop holds open after an upgrade, and the hub that lets one connection's handler reach the others.
gc struct Socket { id: str, route: str, protocol: str, client_ip: str, locals: map[str]str, lock: mutex, out: chan[Out], open: bool, ended: str, close_code: int, close_reason: str, hubs: [Hub], namespaces: map[str]bool, handler: int, handshake: http.Response, sio: bool, accepted: bool }Socket.send_text(s: str) -> boolQueues a text message.
Socket.send_binary(b: bytes) -> boolSocket.emit(namespace: str, name: str, args_json: str) -> boolSocket.IO: emit(name, ...args) on a namespace ("/" by default).
Socket.emit_with_ack(namespace: str, name: str, args_json: str, id: int) -> boolThe same, asking the client to acknowledge: its answer arrives as an Ack event carrying id.
Socket.ack(namespace: str, id: int, args_json: str) -> boolThe answer to a client event that asked for one (SioEvent.ack >= 0).
Socket.emit_binary(namespace: str, name: str, args_json: str, data: [bytes]) -> boolemit with binary attachments: args_json is the other arguments, as for emit, and each of data follows them as a binary...
Socket.disconnect_ns(namespace: str) -> boolDisconnects the client from one namespace; the session stays.
Socket.close(code: int, reason: str) -> boolStarts the closing handshake with code (1000 normal, 1001 going away, 1008 policy, 4000-4999 the application's own) and...
Socket.is_open() -> boolSocket.local(key: str) -> strA value the handshake's hooks set on the request, "" when none did.
Socket.set_local(key: str, v: str) -> intgc struct Hub { id: str, lock: mutex, sockets: map[str]Socket, rooms: map[str]map[str]bool }Sockets by id, and rooms of them: what a chat, a live dashboard or a notification fan-out needs to reach more than the one...
new_hub() -> HubHub.add(s: Socket) -> intHub.remove(s: Socket) -> intTakes the socket out of the hub and every room in it.
Hub.join_room(s: Socket, room: str) -> intPuts the socket in a room (adding it to the hub first if needed).
Hub.leave_room(s: Socket, room: str) -> intHub.count() -> intHub.room_size(room: str) -> intHub.get(id: str) -> opt[Socket]The socket with this id, if it is in the hub.
Hub.broadcast(text: str, except: str) -> intText to every WebSocket in the hub but except (an id, or "").
Hub.broadcast_binary(b: bytes, except: str) -> intHub.to_room(room: str, text: str, except: str) -> intHub.to_room_binary(room: str, b: bytes, except: str) -> intHub.emit_all(namespace: str, name: str, args_json: str, except: str) -> intSocket.IO: one event to every Socket.IO session in the hub, encoded once.
Hub.emit_to_room(room: str, namespace: str, name: str, args_json: str, except: str) -> intHub.emit_binary_to_room(room: str, namespace: str, name: str, args_json: str, data: [bytes], except: str) -> intemit_binary to every Socket.IO session in a room ("" for the hub).
testkit.sl#
Building a request, and reading a response.
gc struct TestRequest { method: Method, path: str, headers: map[str]str, body: bytes }request(method: Method, path: str) -> TestRequestget_request(path: str) -> TestRequestTestRequest.header(name: str, value: str) -> TestRequestTestRequest.bearer(token: str) -> TestRequestTestRequest.basic(credentials: str) -> TestRequestTestRequest.content_type(ct: str) -> TestRequestTestRequest.body_str(body: str) -> TestRequestTestRequest.body_bytes(body: bytes) -> TestRequestTestRequest.json_body(body: str) -> TestRequestSets the content type too, because a body without one is a body a real client would not send.
TestRequest.json(body: Json) -> TestRequestTestRequest.multipart(boundary: str, fields: map[str]str, files: [Upload]) -> TestRequestA multipart body, built from the parts given: each file is name:filename:content-type:content, each field name:value.
TestRequest.build() -> http.RequestNot fallible: a header that fails http.request()'s validation (CR or LF in a name or value, a colon in a name) here is a bug...
upload_part(field: str, filename: str, content_type: str, content: str) -> UploadOne part for multipart, without going near a real encoder.
resp_status(r: http.Response) -> intstatus_of is taken by the registry, so responses read as resp_status, resp_text, resp_header, resp_json.
resp_text(r: http.Response) -> strresp_header(r: http.Response, name: str) -> strresp_json(r: http.Response) -> JsonThe body as JSON, or Null when it is not JSON at all -- so an assertion reads json_of(resp).get("id") without a guard.
resp_error_code(r: http.Response) -> strThe error code in zokor's envelope: "not_found", "validation_failed".
resp_error_fields(r: http.Response) -> [str]The field names an error envelope complains about, in order, so a test asserts on the shape rather than on an exact string.
tokens.sl#
Opaque bearer tokens.
gc struct TokenStore { subjects: map[str]str, expires: map[str]int, ttl: int, lock: mutex, since_sweep: int }new_token_store(ttl_ns: int) -> TokenStorettl_ns is how long a token stays valid after it is issued, in nanoseconds; 0 means until it is revoked.
new_token() -> result[str, str]32 random bytes as 64 lowercase hex characters.
TokenStore.issue(subject: str) -> result[str, str]A new token for subject.
TokenStore.issue_at(subject: str, now: int) -> result[str, str]TokenStore.lookup(token: str) -> opt[str]The subject a live token was issued to; none for an unknown, revoked or expired token (an expired one is dropped on the way).
TokenStore.lookup_at(token: str, now: int) -> opt[str]TokenStore.revoke(token: str) -> boolEnds one token (logout).
TokenStore.revoke_subject(subject: str) -> intEnds every token issued to subject ("log out everywhere", a password change).
TokenStore.count() -> intHow many tokens are held (expired ones count until swept).
bearer_subject[S](c: Ctx[S], store: TokenStore) -> opt[str]The subject behind the request's Authorization: Bearer <token>, or none when the header is missing or the token is not live.
tracing.sl#
Request-scoped W3C context, carried explicitly across tasks and calls.
gc struct TraceConfig { max_parent_bytes: int, max_state_bytes: int, max_state_members: int }Startup limits for incoming W3C headers.
gc struct Tracer { max_parent_bytes: int, max_state_bytes: int, max_state_members: int }Validated, immutable startup tracing settings.
struct TraceContext { trace_id: str, parent_id: str, span_id: str, sampled: bool, state: str }Explicit request context; pass this value when spawning background work.
trace_config() -> TraceConfigDefault limits: 256 parent bytes, 512 state bytes, 32 state members.
new_tracer(cfg: TraceConfig) -> result[Tracer, str]Validate all startup limits together and copy them.
trace_context(t: Tracer, headers: bytes) -> result[TraceContext, str]Invalid, absent or duplicate parents start a new trace.
trace_header(ctx: TraceContext) -> result[str, str]A validated version-00 header; reserved flags are always zero.
trace_request(ctx: TraceContext, req: httpc.Request) -> result[httpc.Request, str]Copy the request and its headers; never mutate a shared caller request.
trace_registry(reg: Registry, ctx: TraceContext) -> RegistryBind a registry by copying its startup references, never changing the shared registry as concurrent requests arrive with...
struct TraceLogger { logger: Logger, trace_id: str, request_id: str }A request binding over a shared Logger, without changing its configuration.
traced_logger(log: Logger, ctx: TraceContext) -> TraceLoggerBind application records to a validated trace id.
upload.sl#
File uploads.
gc struct UploadRules { limits: multipart.Limits, allowed_types: [str], allowed_extensions: [str], require_content_type: bool }gc struct Upload { field: str, filename: str, raw_filename: str, content_type: str, data: bytes }gc struct Form { values: map[str]str, files: [Upload] }gc struct FormError { code: str, detail: str }The reason a form was refused: a registered error code, and a sentence naming what was wrong.
default_uploads() -> UploadRulesuploads_allowing(types: [str]) -> UploadRuleswith_max_file_bytes(r: UploadRules, n: int) -> UploadRuleswith_max_total_bytes(r: UploadRules, n: int) -> UploadRuleswith_max_files(r: UploadRules, n: int) -> UploadRuleswith_extensions(r: UploadRules, exts: [str]) -> UploadRulesparse_form(content_type: str, body: bytes, rules: UploadRules) -> result[Form, FormError]value(f: Form, name: str) -> strhas_value(f: Form, name: str) -> boolfile(f: Form, field: str) -> opt[Upload]The first file posted under a field name, which is the usual case.
files_for(f: Form, field: str) -> [Upload]Every file under one field name: <input multiple> posts repeats.
total_bytes(f: Form) -> intsafe_filename(raw: str) -> strA client's filename, made safe to use as ONE path segment.
extension_of(filename: str) -> str".png" for "a.png", lowercased; "" when there is no extension.
media_type_of(header: str) -> strThe media type without its parameters: "image/png" from "image/png; charset=binary".
ws.sl#
WebSocket, and Socket.IO on top of it.
gc struct WsRules { max_frame_bytes: int, max_message_bytes: int, protocols: [str], ping_interval: int, pong_timeout: int, max_queue: int, close_timeout: int }default_ws() -> WsRulesws_allowing(protocols: [str]) -> WsRulesenum Incoming { Text, Binary, Ping, Pong, Close }gc struct Message { kind: Incoming, text: str, data: bytes, code: int, reason: str }gc struct Conn { rules: WsRules, inbox: builder.Bytes, have: int, need: int, partial: builder.Bytes, partial_op: ws.Op, fragmenting: bool, outbox: bytes, closed: bool, sid: str }new_conn(rules: WsRules) -> Connis_upgrade(req: http.Request) -> boolIs this request asking to become a WebSocket? Cheap enough to call on every request, so a route can serve both.
upgrade(req: http.Request, rules: WsRules) -> result[http.Response, str]The 101 to send back, or the failure to report.
receive(c: Conn, chunk: bytes) -> result[[Message], str]Feeds bytes in and takes whole messages out.
pending(c: Conn) -> bytesBytes the connection owes the peer (pongs, the close echo), taken out: call it after receive and write whatever it returns.
is_closed(c: Conn) -> booltext_frame(s: str) -> bytesbinary_frame(b: bytes) -> bytesping_frame(payload: bytes) -> bytespong_frame(payload: bytes) -> bytesclose_frame(code: int, reason: str) -> bytesclose_because(c: Conn, why: str) -> bytesThe close a protocol error calls for: 1002, with the reason, and the connection marked shut so nothing else is sent.
gc struct SioSettings { ping_interval_ms: int, ping_timeout_ms: int, max_payload: int, max_polling_sessions: int, max_attachments: int }default_sio() -> SioSettingsenum SioKind { Open, Connect, Event, Ack, Ping, Disconnect, Other }gc struct SioEvent { kind: SioKind, namespace: str, name: str, args_json: str, ack: int, attachments: [bytes], expecting: int }sio_open(c: Conn, sid: str, s: SioSettings) -> bytesThe first thing a socket.io connection needs: its session id and the keepalive timings, as a text frame.
sio_receive(c: Conn, m: Message) -> result[SioEvent, str]Turns one WebSocket text message into a socket.io event.
sio_connect_ok(c: Conn, namespace: str) -> bytesThe answer to a CONNECT.
sio_connect_error(namespace: str, why: str) -> bytessio_emit(namespace: str, name: str, args_json: str) -> bytesemit(name, args).
sio_emit_with_ack(namespace: str, name: str, args_json: str, id: int) -> bytessio_ack(namespace: str, id: int, args_json: str) -> bytesThe reply to an event that carried an ack id.
sio_disconnect(namespace: str) -> bytesws_serve.sl#
WebSocket routes, served: the handshake goes through the router like any request -- its befores run, so authentication and...
gc struct WsRoute[S] { rules: WsRules, message: opt[fn(S, Socket, Message)], closed: opt[fn(S, Socket, int, str)], sio: opt[SioRoute[S]] }ws_route[S](rules: WsRules, message: fn(S, Socket, Message)) -> WsRoute[S]The message handler is the one a route cannot do without; on_close is optional.
WsRoute.on_close(f: fn(S, Socket, int, str)) -> WsRoute[S]Called once when the connection ends, however it ended, with the code and reason: the peer's, the ones this side closed with,...
internal/multipart (reached as UploadRules.limits)#
gc struct Limits { max_parts: int, max_files: int, max_file_bytes: int, max_field_bytes: int, max_total_bytes: int, max_headers_bytes: int, max_filename_bytes: int }