Skip to content

Filesystem and HTTP

Status: Implemented (API 1.20). A file API for all languages that reaches only the mod’s own folder (writes only data/), and permission-gated HTTPS with answers on the tick. Lua also gets cl.json.

Function tables: fs, http, mod. Guides: Saved Data, Data Attachments.

Paths

Every path is relative to the mod’s folder, mods/<id>/, with / between folders.

Path What it is Read Write
clight.mod.json, scripts/..., assets/..., datapack/... The mod’s own files ✔ ✘
data/... The mod’s data folder mods/<id>/data/ (under clight run: the project’s build/run/<id>/data/) ✔ ✔

The engine refuses, with CL_E_PERMISSION_DENIED:

  • absolute paths (C:/..., \\server\...), drive letters and ..,
  • device names (con, nul, com1, …), : streams, control characters, names ending in a dot or a space,
  • links and junctions that lead out of the folder (they are not followed, not listed and not written through),
  • writes, renames and removals outside data/,
  • executable file types in data/: .exe .dll .bat .cmd .ps1 .com .scr .msi .vbs .js .jar .lnk .sh .so .dylib and the rest of the list in sandbox.cpp.

Writes are atomic: the bytes go to a temporary file that then replaces the old one, so a crash never leaves half a file. data/ holds at most 256 MiB (CL_E_LIMIT beyond it); one read or write is at most 64 MiB; a folder listing at most 4096 entries.

Files

C Lua
cl_fs_read(path) -> cl_buf (owned) cl.fs.read(path) → string or nil, message the whole file
cl_fs_read_text(path, buf, cap, &len) (same) as text, two-call
cl_fs_write(path, buf) / cl_fs_write_text(path, text) cl.fs.write(path, text) → true or nil, message replace the file; folders are made
cl_fs_append_text(path, text) cl.fs.append(path, text) add at the end
cl_fs_exists(path) cl.fs.exists(path)
cl_fs_stat(path, &info) (kind CL_FS_FILE / CL_FS_DIR, size, modified_ms) cl.fs.stat(path) → { kind = "file", size, modified }
cl_fs_list(path, buf, cap, &len) (one name per line, folders end with /) cl.fs.list(path) → a list sorted
cl_fs_mkdir(path) cl.fs.mkdir(path) with parents
cl_fs_remove(path, recursive) cl.fs.remove(path, recursive) data/ itself stays
cl_fs_rename(from, to) cl.fs.rename(from, to) inside data/
cl_fs_data_used(), cl_fs_data_quota() cl.fs.used(), cl.fs.quota() bytes
cl.fs.read_json(path), cl.fs.write_json(path, value) through cl.json

Lua’s file functions return nil plus a message instead of raising, like io.open. C and C++ also keep WASI: fopen("/data/scores.txt", "w") is the same file as cl_fs_write_text("data/scores.txt", ...) (the mod’s folder is mounted read-only at /, data/ read/write at /data). WASI writes count against the quota at the next file API call.

local scores = cl.fs.read_json("data/scores.json") or {}
scores[player.name] = (scores[player.name] or 0) + 1
local ok, err = cl.fs.write_json("data/scores.json", scores)
if not ok then cl.log.warn("could not save: %s", err) end
cl_fs_write_text(CL_STR("data/notes.txt"), CL_STR("hello"));
char buf[256]; size_t n = 0;
if (cl_fs_read_text(CL_STR("data/notes.txt"), buf, sizeof buf, &n) == CL_OK) { /* buf holds n bytes */ }

cl.json.encode(value) and cl.json.decode(text) (Lua only): tables with the keys 1…n are arrays, other tables objects; null becomes nil (a hole in an array); NaN and infinities can’t be encoded.

HTTP

A mod may reach a host only when it lists the capability in its manifest and the player allowed it:

"permissions": ["net.http:api.ruby-tools.example"],
"optional_permissions": ["net.http:*.cdn.example"]
Capability Reaches
net.http:api.example.com that host (any port)
net.http:*.example.com its subdomains (img.example.com), not example.com itself
net.http:* any host (highlighted on the Permissions screen)

Rules the engine enforces for every request:

  • https only. Plain http:// only to localhost, 127.0.0.1 and [::1] (and still with a capability for that host).
  • Redirects are followed (up to 10) only to hosts the mod may reach, never from https to http.
  • No cookies are stored or sent. The engine sets User-Agent: Celestial-CLight/<api> (<mod id>/<version>); mod headers may not set Host, Content-Length, Connection, Transfer-Encoding, Cookie, User-Agent, TE, Upgrade, Expect, Keep-Alive, Trailer, Proxy-* or Sec-*.
  • Limits: the whole request takes at most timeout_ms (1 to 60 s, default 15 s); the answer body at most max_bytes (default 8 MiB, at most 64 MiB); a request body at most 8 MiB; 4 requests run at once per mod, 32 more may wait (CL_E_LIMIT beyond).
  • Requests run on two background threads, never on the tick or render thread.
  • The answer arrives on the side that asked, at its next tick: a request from the client is answered in the client tick (or the next frame), one from the server thread in the server tick. The callback runs in that tick’s context, so it may use the whole API.
cl_result cl_http_request(cl_str url, const cl_http_opts* opts, cl_http_fn fn, void* user, cl_http_req* out);
typedef void (*cl_http_fn)(cl_http_req req, int32_t status, cl_buf body, void* user);

cl_http_opts: method (CL_HTTP_GET, _POST, _PUT, _DELETE, _HEAD, _PATCH), body, content_type, headers (one Name: value per line), timeout_ms, max_bytes, save_to. The callback gets the HTTP status, or 0 when no answer came; cl_http_error(req, ...) then says why (timeout, cancelled, not allowed: <host>, too large, host not found, TLS failure, redirect refused, save failed: ...). cl_http_header(req, "Content-Type", ...) reads a response header (any case). body is valid during the callback; the request handle is valid until the callback returns. cl_http_cancel(req) still runs the callback, with status 0 and cancelled. cl_http_allowed(url_or_host) tells whether a request would be allowed now.

save_to: a data/ path. A 2xx body is written there (atomic, counted against the data quota) and the callback gets an empty body. Other statuses hand the body to the callback as usual. Nothing downloaded is ever run.

Lua:

cl.http.get("https://api.ruby-tools.example/v1/prices", function(res)
  if not res.ok then
    cl.log.warn("prices: %s", res.error or res.status)
    return
  end
  local prices = res.json()
  cl.log("ruby costs %d", prices.ruby)
end)

cl.http.post("https://api.ruby-tools.example/v1/scores", { player = "Alex", score = 12 }, function(res)
  cl.log("saved: %d", res.status)
end)

cl.http.request{ url = "https://img.cdn.example/pack.zip", save_to = "data/pack.zip", timeout = 30,
                 on_done = function(res) cl.log("downloaded: %s", tostring(res.ok)) end }

res: status, ok (2xx and no error), body (text), error (nil when an answer came), header(name), json(). cl.http.get / post / request return the request (req:cancel()) or nil plus a message (not allowed, a bad URL, too many). A table body to post is sent as JSON.

Capabilities from code

C Lua
cl_cap_granted(cap) cl.permission(cap) the player allowed a capability the mod declared
cl_cap_request(cap) cl.request_permission(cap) asks now

cl_cap_request opens the mod’s Permissions screen when the capability is still undecided and returns CL_E_BUSY (Lua: nil); the answer fires mod.permission_changed (capability, granted) to that mod only. CL_OK (Lua: true): already allowed. CL_E_PERMISSION_DENIED (Lua: false): the player said no before (they can change it on the Mods screen), or the mod did not declare it. A dedicated server has no screen: CL_E_UNSUPPORTED; grants come from config/clight/permissions.json.

if not cl.permission("net.http:*.cdn.example") then
  cl.request_permission("net.http:*.cdn.example")
end
cl.on("mod.permission_changed", function(e)
  if e.capability == "net.http:*.cdn.example" and e.granted then start_downloads() end
end)

Also