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 getscl.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 .dyliband the rest of the list insandbox.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 tolocalhost,127.0.0.1and[::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 setHost,Content-Length,Connection,Transfer-Encoding,Cookie,User-Agent,TE,Upgrade,Expect,Keep-Alive,Trailer,Proxy-*orSec-*. - Limits: the whole request takes at most
timeout_ms(1 to 60 s, default 15 s); the answer body at mostmax_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_LIMITbeyond). - 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
- Config files: Configuration. Values on entities, chunks and the world: Persistence and data attachments.
- The sandbox as a whole: Security, the sandbox and capabilities.