neyuki 0.6.1

A small Lua-like scripting language implemented in Rust
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
local table = require("@neyuki/table")
local string = require("@neyuki/string")
local io = require("@neyuki/io")

local http = {}

function checkString(s: string, name: string)
    if (s == nil) then error(name .. " must be provided") end
    if (type(s) != "string") then error(name .. " must be a string") end
end

function checkOptionalString(s: string?, name: string)
    if (s != nil) then checkString(s, name) end
end

function checkOptionalNumber(n: number?, name: string)
    if (n != nil and type(n) != "number") then error(name .. " must be a number") end
end

function checkOptionalBoolean(b: boolean?, name: string)
    if (b != nil and type(b) != "boolean") then error(name .. " must be a boolean") end
end

function checkOptionalTable(t: {any}?, name: string)
    if (t != nil and type(t) != "table") then error(name .. " must be a table") end
end

-- Every header name and value must be a string or a number; names are sent
-- as given, so callers can pick their own casing.
function checkHeaders(headers: {[string]: string}?, name: string)
    checkOptionalTable(headers, name)
    if (headers == nil) then return end
    for key, value in headers do
        if (type(key) != "string") then error(name .. " must be keyed by header names") end
        if (type(value) != "string" and type(value) != "number") then
            error("header `" .. key .. "` must be a string or a number")
        end
    end
end

-- Reads one header from a lower-cased table by any casing of its name.
function findHeader(headers: {[string]: string}, name: string): string?
    checkString(name, "name")
    return headers[string.lower(name)]
end

function makeResponse(raw: {[string]: any}): Response
    local response = {}
    response.status = raw.status
    response.ok = raw.status >= 200 and raw.status < 300
    response.headers = table.freeze(raw.headers)
    response.body = raw.body
    response.url = raw.url

    function response.header(self: Response, name: string): string?
        return findHeader(raw.headers, name)
    end

    return table.freeze(response)
end

-- Performs one request. `options` may hold:
--   method   the HTTP method (default "GET")
--   headers  a { name = value } table
--   body     the request body string
--   timeout  seconds to wait for the whole exchange (default: no limit)
--   follow   whether redirects are followed (default true)
--   maxBody  the largest response body accepted, in bytes (default 64 MiB);
--            a larger one raises an error
-- Only transport failures raise errors; any status code is returned as a
-- `Response` with `ok` set for 2xx.
function http.request(url: string, options: RequestOptions?): Response
    checkString(url, "url")
    checkOptionalTable(options, "options")
    options = options ?? {}
    checkOptionalString(options.method, "method")
    checkHeaders(options.headers, "headers")
    checkOptionalString(options.body, "body")
    checkOptionalNumber(options.timeout, "timeout")
    checkOptionalBoolean(options.follow, "follow")
    checkOptionalNumber(options.maxBody, "maxBody")

    local raw = __http_request(
        options.method ?? "GET",
        url,
        options.headers,
        options.body,
        options.timeout,
        options.follow ?? true,
        options.maxBody
    )
    return makeResponse(raw)
end

-- Builds a copy of `options` with `method` (and optionally `body`) set, so
-- the shorthands never mutate the table the caller passed in.
function withMethod(options: RequestOptions?, method: string, body: string?): RequestOptions
    checkOptionalTable(options, "options")
    local merged = {}
    if (options != nil) then
        for key, value in options do
            merged[key] = value
        end
    end
    merged.method = method
    if (body != nil) then merged.body = body end
    return merged
end

function http.get(url: string, options: RequestOptions?): Response
    return http.request(url, withMethod(options, "GET"))
end

function http.head(url: string, options: RequestOptions?): Response
    return http.request(url, withMethod(options, "HEAD"))
end

function http.delete(url: string, options: RequestOptions?): Response
    return http.request(url, withMethod(options, "DELETE"))
end

function http.post(url: string, body: string?, options: RequestOptions?): Response
    checkOptionalString(body, "body")
    return http.request(url, withMethod(options, "POST", body))
end

function http.put(url: string, body: string?, options: RequestOptions?): Response
    checkOptionalString(body, "body")
    return http.request(url, withMethod(options, "PUT", body))
end

function http.patch(url: string, body: string?, options: RequestOptions?): Response
    checkOptionalString(body, "body")
    return http.request(url, withMethod(options, "PATCH", body))
end

-- Percent-encodes `s` for use in a URL path or query component.
function http.encode(s: string): string
    checkString(s, "s")
    return __http_url_encode(s)
end

-- Reverses `http.encode`; also turns `+` into a space as forms do.
function http.decode(s: string): string
    checkString(s, "s")
    return __http_url_decode(s)
end

-- Turns a { name = value } table into a `a=1&b=two` query string with the
-- keys sorted, so the output is stable.
function http.query(params: {[string]: any}): string
    if (type(params) != "table") then error("params must be a table") end
    local keys = {}
    for key, value in params do
        if (type(key) != "string") then error("params must be keyed by strings") end
        if (type(value) != "string" and type(value) != "number" and type(value) != "boolean") then
            error("param `" .. key .. "` must be a string, a number or a boolean")
        end
        table.insert(keys, key)
    end
    table.sort(keys, function(a: string, b: string): boolean return a <= b end)
    local parts = {}
    for _, key in keys do
        table.insert(parts, __http_url_encode(key) .. "=" .. __http_url_encode(tostring(params[key])))
    end
    return table.concat(parts, "&")
end

-- Splits a `path?query` string into its path and a decoded { name = value }
-- table of query parameters. Repeated names keep the last value.
function http.parseQuery(target: string): (string, {[string]: string})
    checkString(target, "target")
    local params = {}
    local mark = string.find(target, "?", 1, true)
    if (mark == nil) then return target, params end
    local path = string.sub(target, 1, mark - 1)
    local query = string.sub(target, mark + 1)
    for pair in string.gmatch(query, "[^&]+") do
        local equals = string.find(pair, "=", 1, true)
        if (equals == nil) then
            params[__http_url_decode(pair)] = ""
        else
            params[__http_url_decode(string.sub(pair, 1, equals - 1))] = __http_url_decode(string.sub(pair, equals + 1))
        end
    end
    return path, params
end

-- Normalizes what a handler may return into status, headers and body:
--   nil       204 with an empty body
--   a string  200 with that body as text/plain
--   a table   { status?, headers?, body? }
function normalizeReply(reply: any): (number, {[string]: string}, string)
    if (reply == nil) then return 204, {}, "" end
    if (type(reply) == "string") then
        return 200, { ["content-type"] = "text/plain; charset=utf-8" }, reply
    end
    if (type(reply) != "table") then
        error("a response must be nil, a string or a table, got " .. type(reply))
    end
    checkOptionalNumber(reply.status, "status")
    checkHeaders(reply.headers, "headers")
    checkOptionalString(reply.body, "body")
    return reply.status ?? 200, reply.headers ?? {}, reply.body ?? ""
end

-- Joins a host and a port into `host:port`, bracketing IPv6 literals.
function hostPort(host: string, port: number): string
    if (string.find(host, ":", 1, true) != nil) then host = "[" .. host .. "]" end
    return host .. ":" .. tostring(port)
end

-- Splits a forward target into a host and a port. A number is a port on
-- `localhost`, which reaches a server bound to either 127.0.0.1 or ::1 (dev
-- servers on Node often listen on ::1 only); a table holds `host` (default
-- "localhost") and `port`.
function targetAddress(target: ForwardTarget, name: string): (string, number)
    if (type(target) == "number") then return "localhost", target end
    if (type(target) != "table") then error(name .. " must be a port number or a { host, port } table") end
    checkOptionalString(target.host, name .. ".host")
    if (type(target.port) != "number") then error(name .. ".port must be a number") end
    return target.host ?? "localhost", target.port
end

-- Like `targetAddress`, but also accepts a base URL string and returns the
-- target as one.
function targetUrl(target: ForwardTarget): string
    if (type(target) == "string") then
        if (string.find(target, "://", 1, true) == nil) then
            error("target must be a port number, a { host, port } table or a URL with a scheme")
        end
        return target
    end
    local host, port = targetAddress(target, "target")
    return "http://" .. hostPort(host, port)
end

function makeRequest(raw: {[string]: any}): Request
    local answered = false
    local request = {}
    request.method = raw.method
    request.path = raw.path
    request.headers = table.freeze(raw.headers)
    request.body = raw.body
    request.remote = raw.remote

    function request.header(self: Request, name: string): string?
        return findHeader(raw.headers, name)
    end

    -- Splits the request target into its path and query parameters.
    function request.query(self: Request): (string, {[string]: string})
        return http.parseQuery(raw.path)
    end

    -- Sends the reply (see `normalizeReply`). Each request is answered once.
    function request.respond(self: Request, reply: any): ()
        if (answered) then error("request was already answered") end
        local status, headers, body = normalizeReply(reply)
        answered = true
        __http_respond(raw.id, status, headers, body)
    end

    function request.answered(self: Request): boolean
        return answered
    end

    -- Answers this request with whatever `target` replies to it: the method,
    -- path, headers and body go upstream unchanged and the status, headers
    -- and body come back byte-for-byte, so a handler can `return
    -- request:forward(5173)` for anything it does not serve itself. `target`
    -- is a port on localhost, a { host?, port } table or a base URL such as
    -- "https://api.example.com"; `options.timeout` bounds the upstream call.
    -- If the upstream cannot be reached the client gets a 502 and the error
    -- is raised so `serve` reports it. WebSocket upgrades cannot pass
    -- through here; use `http.forward` for those.
    function request.forward(self: Request, target: ForwardTarget, options: ForwardOptions?): ()
        if (answered) then error("request was already answered") end
        local base = targetUrl(target)
        checkOptionalTable(options, "options")
        options = options ?? {}
        checkOptionalNumber(options.timeout, "timeout")
        answered = true
        __http_proxy(raw.id, base, options.timeout)
    end

    return table.freeze(request)
end

-- Opens a listening socket. `options` may be a port number or a table with
-- `host` (default "127.0.0.1"), `port` (default 8080; 0 picks a free one) and
-- `maxBody` (the largest request body accepted, in bytes; default 64 MiB).
-- Larger requests are answered with 413 and never returned by `accept`.
function http.listen(options: ServerOptions | number | nil): Server
    if (type(options) == "number") then options = { port = options } end
    checkOptionalTable(options, "options")
    options = options ?? {}
    checkOptionalString(options.host, "host")
    checkOptionalNumber(options.port, "port")
    checkOptionalNumber(options.maxBody, "maxBody")
    local host = options.host ?? "127.0.0.1"

    local id, port = __http_listen(host, options.port ?? 8080, options.maxBody)
    local closed = false
    local server = {}
    server.host = host
    server.port = port
    server.url = "http://" .. host .. ":" .. tostring(port)

    function checkOpen()
        if (closed) then error("server on " .. server.url .. " is closed") end
    end

    -- Waits for the next request. With `timeout` (in seconds) it returns nil
    -- once that long passes without one; without it, it waits indefinitely.
    function server.accept(self: Server, timeout: number?): Request?
        checkOpen()
        checkOptionalNumber(timeout, "timeout")
        local raw = __http_accept(id, timeout)
        if (raw == nil) then return nil end
        return makeRequest(raw)
    end

    function server.close(self: Server): ()
        if (closed) then return end
        closed = true
        __http_close(id)
    end

    function server.isOpen(self: Server): boolean
        return not closed
    end

    return table.freeze(server)
end

-- Forwards every TCP connection made to `options` (a port number or a
-- { host?, port? } table, as for `listen`) to `target` (a port number on
-- localhost or a { host?, port } table). Bytes pass through untouched, so anything that
-- speaks TCP works: HTTP, WebSockets (a Vite dev server's HMR included),
-- TLS, databases. The relaying happens on background threads, so the script
-- carries on; call `wait()` on the returned `Forward` to keep a script that
-- does nothing else alive, or run `serve` alongside it.
function http.forward(options: ServerOptions | number | nil, target: ForwardTarget): Forward
    if (type(options) == "number") then options = { port = options } end
    checkOptionalTable(options, "options")
    options = options ?? {}
    checkOptionalString(options.host, "host")
    checkOptionalNumber(options.port, "port")
    local host = options.host ?? "127.0.0.1"
    local targetHost, targetPort = targetAddress(target, "target")

    local id, port = __http_forward(host, options.port ?? 8080, targetHost, targetPort)
    local closed = false
    local forward = {}
    forward.host = host
    forward.port = port
    forward.url = "http://" .. hostPort(host, port)
    forward.target = hostPort(targetHost, targetPort)

    -- Blocks for `timeout` seconds, or indefinitely without one.
    function forward.wait(self: Forward, timeout: number?): ()
        if (closed) then error("forward on " .. forward.url .. " is closed") end
        checkOptionalNumber(timeout, "timeout")
        __http_forward_wait(id, timeout)
    end

    -- Stops accepting connections; ones already relayed finish on their own.
    function forward.close(self: Forward): ()
        if (closed) then return end
        closed = true
        __http_unforward(id)
    end

    function forward.isOpen(self: Forward): boolean
        return not closed
    end

    return table.freeze(forward)
end

-- Runs `handler(request)` for every incoming request, forever. A handler's
-- return value is sent with `request:respond` unless the handler already
-- answered; an error inside the handler is reported on stderr and answered
-- with a 500 so the server keeps going.
function http.serve(options: ServerOptions | number | nil, handler: (Request) -> any): ()
    if (type(handler) != "function") then error("handler must be a function") end
    local server = http.listen(options)
    while (true) do
        local request = server:accept()
        local ok, reply = try(handler, request)
        if (ok) then
            if (not request:answered()) then request:respond(reply) end
        else
            io.stderr:write("http: handler failed for ", request.method, " ", request.path, ": ", tostring(reply), "\n")
            if (not request:answered()) then
                request:respond({ status = 500, body = "Internal Server Error" })
            end
        end
    end
end

return table.freeze(http)