| name | javascript |
| description | Use for Synchronet's JavaScript language and host API — the SpiderMonkey 1.8.5 dialect constraints, the object/class model (MsgBase, FileBase, File, User, system, msg_area/file_area, Socket, MQTT, etc.), how those APIs actually behave (including sharp edges like MsgBase.get_all_msg_headers() lazy fields), writing tests in exec/tests/, and the stock exec/*.js module ecosystem as API references. Trigger when authoring or debugging Synchronet .js/.ssjs/.xjs code, asking "how does the MsgBase/User/FileBase API work", "why is h.to_ext undefined", "why is JSON.parse failing on only some lines of my .jsonl", "what JS can I use in Synchronet", or "is there a stock script that does X". For *running* JS (jsexec flags, invocation, debug builds) see the jsexec skill; for storage-layer SMB repair see smbutils. |
Synchronet JavaScript (the language + host API)
This skill is about writing and reasoning about Synchronet JavaScript — the
dialect, the host object model, and how the APIs behave. To actually run a
script or inline probe (jsexec flags, invocation modes, capturing output,
Windows debug builds), use the jsexec skill. For low-level SMB
file repair (smbutil/chksmb/fixsmb) use smbutils.
Start here: the wiki's JavaScript hub — https://wiki.synchro.net/custom:javascript
— covers the engine, invocation contexts, the object model, load()/require(),
and the output/input method tables. The full reference list (object-model docs,
MDN, ECMA-262, libraries) is in the References section below.
Quick pointers:
Before hand-rolling a utility, look for a native one
Synchronet exposes a large API — not just the class/object model but a deep
global-function surface (date/time, string, formatting, math, file
helpers) defined in src/sbbs3/js_global.cpp. It's easy to reinvent something
the engine already has (e.g. writing a manual ISO-8601 timestamp formatter when
strftime("%Y-%m-%dT%H:%M:%S%z", time()) exists). Check first.
Fastest lookup is the bundled parser over the in-tree object-model docs
(docs/jsobjs.html, generated by jsexec jsdocs.js):
scripts/jsobjs.py <pattern>
scripts/jsobjs.py strftime
scripts/jsobjs.py 'date|time|iso'
scripts/jsobjs.py --obj system
scripts/jsobjs.py
It reads the live docs/jsobjs.html each run (auto-discovered by walking up
from the script / CWD), so it never goes stale against the checkout. If the
file is missing, regenerate with jsexec jsdocs.js. Falling back: grep the
authoritative C++ bindings directly — grep '"name"' src/sbbs3/js_global.cpp
for globals, or src/sbbs3/js_<obj>.c* for a class.
Filesystem: globbing and listing subdirectories
directory(pattern [,flags]) globs the filesystem and returns full paths. Its
default flags are GLOB_MARK (js_global.cpp), which appends a trailing /
to every directory entry — so directories are self-identifying. To list a dir's
subdirs, glob dir + "*" and keep the entries ending in /:
var subs = [], all = directory(js.exec_dir + "*"), i;
for (i = 0; i < all.length; i++)
if (all[i].charAt(all[i].length - 1) == "/") subs.push(all[i]);
(file_isdir(path) also works but stats each entry; the GLOB_MARK slash is free.
Pass other GLOB_* bits as the 2nd arg to override the default.) Companions:
file_exists, file_getname (basename), fullpath.
Always build absolute paths from js.exec_dir / system.*_dir — never rely on
the CWD. A bare relative path resolves against the process working
directory, which under the BBS is always Synchronet's ctrl/ dir. sbbs is
multi-threaded, so the CWD is process-global — it cannot be per-node — which
is precisely why no chdir is exposed to JS in the BBS (only the
single-process jsexec tool has one). js.exec_dir is the running script's own
directory (trailing slash); resolve sibling/relative paths against it.
Downloading and unpacking: HTTPRequest + Archive
Don't shell out to curl/wget/unzip/bsdtar — and don't ship a .sh helper
that does. The engine covers both halves portably (and a shell script won't run
on Windows, nor from install-xtrn.js, which only executes .js):
load("http.js");
var req = new HTTPRequest();
req.follow_redirects = 5;
var n = req.Download(url, dest_file);
if (req.response_code != 200) { }
var ar = new Archive(dest_file);
ar.list();
ar.extract(dest_dir, "SUB/DIR/FILE.EXT");
Archive is libarchive, so it reads far more than ZIP — ISO9660 images
included, which means a CD image can be listed and selectively extracted with
no mount and no external tool. extract() does not preserve the archived
file's mode; it writes per the process umask (use file_chmod() if the mode
matters).
Hashing: File.md5_hex / File.sha1_hex (and the _base64 variants) are
computed streaming over the open file, so they're safe on multi-hundred-MB
inputs — but the file must be open()ed first, and there is no SHA-256 in the
JS API. Pin large downloads with SHA-1 (file_chksum, crc32_calc, md5_calc
also exist; see js_global.cpp / js_file.cpp).
⚠️ TLS trap: cryptlib rejects legacy certificate chains
Synchronet's HTTPS client is cryptlib, which validates every certificate the
server presents and refuses chains containing a legacy (e.g. 1024-bit) root,
even when the chain is otherwise valid and every browser and curl accepts it.
The handshake is torn down before any response, so the failure surfaces from
http.js as a misleading:
Error: Unable to read status
preceded on the console by TLS WARNING 'Server provided invalid certificate chain: Invalid TLS certificate chain entry #N ...'. This is not an HTTP
problem — the request never went out. Real case: https://archive.org serves a
chain ending in the cross-signed 1024-bit Go Daddy Class 2 root, so HTTPRequest
cannot reach it at all, while GitHub and most modern hosts are fine.
Diagnose by confirming the same URL works outside Synchronet and inspecting what
the server actually presents:
curl -sI -o /dev/null -w '%{http_code}\n' https://host/path
echo | openssl s_client -connect host:443 -servername host 2>/dev/null \
| grep '^ *[0-9] s:'
Workarounds, in order of preference: pick a host with a modern chain; or, if the
host redirects to an acceptable one, request it over http:// and let
follow_redirects land on the good https:// host — then verify the payload by
checksum so integrity never depends on the transport.
Environment variables: there is no getenv()
Nothing in Synchronet's JS exposes a getenv(). Under jsexec there is a
global env object, keyed by name (env.SBBSCTRL, env.PATH) — an object,
not an array, so env[0] and env.length are undefined; iterate with
for (var k in env). It is jsexec-only: a script under bbs.exec() or a
logon flow gets a ReferenceError. See the jsexec skill.
For install paths, don't reach for the environment at all. system.ctrl_dir,
system.data_dir, system.exec_dir, system.text_dir, system.mods_dir exist
in both contexts and name the install the script is actually running against —
whereas $SBBSCTRL only reports what the launching shell exported, and is absent
under the BBS. (Note also that $SBBS is not a real Synchronet variable.)
This matters for any script that can be launched both ways. install-xtrn.js
runs an installer's [exec:<file>.js] steps with js.exec() — in the same
process — and is itself run either from jsexec or from inside the BBS
(xtrn-setup.js), so a door's getcore.js/getwads.js runs under whichever the
sysop chose.
Detect the context; don't guess it — and probe with js.global.<name>. A bare
console under jsexec throws ReferenceError, but js.global.console is just a
property lookup and reads undefined. That is why the stock code spells it that
way (exec/logonlist.js, exec/load/gettext.js, and install-xtrn.js itself):
if (!js.global.bbs) { alert("This module must be run from the BBS"); exit(1); }
if (js.global.console) { console.putmsg(...); } else { print(...); }
And most I/O already works in both contexts — print() is far from the only
option. write, writeln, printf, alert, log, read, readln, prompt,
confirm and deny are all present in a BBS session and under jsexec; only
console.* / bbs / user are terminal-bound (and uifc/env/conio are
jsexec-only). Full table: https://wiki.synchro.net/custom:javascript#output.
The dialect: SpiderMonkey 1.8.5 (ES3-ish + a few ES5 bits)
Synchronet embeds SpiderMonkey 1.8.5 (JavaScript-C 1.8.5, 2011). Write to
roughly ES3 + named-function-expressions.
Engine version by Synchronet release: v3.14 → JS 1.5, v3.15 → JS 1.7,
v3.16 → JS 1.8.5. The current in-development release, v3.22, still uses
SpiderMonkey 1.8.5. A move to SpiderMonkey 128 is slated for v3.30 and
is being developed on the next-js branch of sbbs.
What this means for what you write today:
- The runtime is 1.8.5, so 1.8.5 is the floor — modern syntax that 1.8.5 can't
parse will fail now.
- But the stock/included scripts are expected to be forward-compatible with
SM-128. So write code that runs on 1.8.5 and won't break under SM-128:
avoid 1.8.5-only quirks (e.g. E4X XML literals, the non-standard
for each…in,
expression closures) that SM-128 removes. Stay on the standard ES3/ES5 subset
that both engines accept.
- Hold off on large new C/C++ JS bindings until SM-128 lands (the binding
layer is what's churning on
next-js); small script tweaks are fine.
Not available (parse/runtime errors in 1.8.5):
let / const (use var), arrow functions, template literals
class, destructuring, spread/rest, default params, generators
Array.prototype.includes/find/findIndex, Object.assign, String.prototype.repeat
Available (1.8.5 has these): JSON, Array.prototype.forEach/map/filter/ indexOf/reduce, Object.keys, getters/setters, try/catch/finally, e4x
XML literals (avoid). When in doubt, match the idioms in nearby exec/*.js.
The host object model
The full surface depends on context. Under jsexec (no user session) you get
the system/data objects but not the session-bound ones (bbs, console,
client) — that distinction is documented in the jsexec skill.
The classes themselves behave identically wherever they're bound.
| Group | Objects |
|---|
| Globals | system, js, server, client (session), conio (jsexec) |
| Session | bbs, console, user (only in a BBS/login context) |
| Areas | msg_area, file_area, xtrn_area |
| Data | User, MsgBase, FileBase, File, Archive, Queue |
| Network | Socket, ConnectedSocket, ListeningSocket, MQTT, COM, HTTPRequest/HTTPReply |
| Crypto | CryptContext, CryptKeyset, CryptCert |
| UI | uifc (jsexec full-screen), console (terminal, session) |
Host objects serialize correctly with JSON.stringify — including nested
sub-objects (User.stats, User.security, User.limits). Don't hand-roll a
for (k in obj) dump; JSON.stringify(obj, null, 2) is equivalent and shorter.
Where Synchronet JavaScript runs (invocation contexts)
.js files normally live in exec/; .ssjs files live in the web hierarchy.
Scripts are not compiled — they're loaded at execution time, so editing a
.js takes effect on its next run (no server recycle needed). Put modified
copies of stock scripts in mods/ so upgrades don't overwrite them (mods/
shadows exec/).
The same script can be driven from several hosts, and the available globals
differ by host:
| Context | How it's launched | Notes |
|---|
| Terminal Server | timed event, external/door, login/logon/newuser module, sysop EXEC; on a command-line prefix the module name with ? or * (e.g. ?newslink) | full session: bbs, console, user, client |
| Web Server | .ssjs pages and *.js under web/root/ | generates HTTP responses |
| Services | exec/*service.js, configured in ctrl/services.ini | all stock services are JS; static or dynamic |
| Mail Server | inbound mail processors, exec/mailproc_example.js + ctrl/mailproc.ini | |
| jsexec | standalone (CGI, daemon, one-off probe) | no session — see jsexec |
From Baja: exec "?modname" / exec "*modname" / exec_bin "modname".
Script lifecycle: exit handlers (js.on_exit)
js.on_exit("<string>") registers a string to evaluate when the script
terminates (a LIFO stack — last registered runs first). It is Synchronet's
atexit — there is no atexit(). Use it for "always run this cleanup no
matter how we leave" (flush a save, release a lock, restore the terminal) in
long-lived modules, services, and doors.
It fires on every exit path, including forced termination — and forced
termination is NOT a catchable exception. When the sysop terminates/recycles
the node (or jsexec gets a SIGTERM), the engine aborts the script through its
operation callback, which returns false after emitting only a warning
("Terminated", js_CommonOperationCallback in src/sbbs3/js_internal.cpp).
That is an uncatchable abort: no JS exception is thrown, so a surrounding
try { … } catch { … } finally { … } does not engage — neither the catch
nor the finally runs. (Verified: SIGTERM a spinning script with a catch, a
finally, and an on_exit handler that each write a marker file — only the
on_exit marker appears.) An on_exit handler still runs because js_EvalOnExit
disables auto-terminate before evaluating the handlers, so their File I/O
completes during teardown.
⚠️ Register on_exit at the script's top level — not inside a function
This is the subtle, expensive one, and it differs by how the script was
launched. At exit the host calls js_EvalOnExit(cx, obj, …); the handler
string is compiled and executed against obj, and obj depends on the
launcher:
jsexec, login/logon/timed-event modules run in the global object, and
the host evaluates on_exit against the global (jsexec.cpp passes
js_glob). That branch runs the global handler list and recurses every
child scope, so it finds a handler no matter where it was registered.
- Doors, and most
bbs.exec / ;exec invocations (and js.exec(file, {}))
run in a fresh child scope object — js_scope, created in
sbbs_t::js_execfile (exec.cpp:595) — and the host evaluates on_exit against
that js_scope (exec.cpp:701). That non-global branch looks up only
js_scope and does not recurse. (;exec reaches this via
str_cmds.js → bbs.exec.)
Meanwhile registration records the scope chain at the moment you call
js.on_exit() (JS_GetScopeChain, js_internal.cpp): at the script's top
level that's js_scope; inside a function it's that function's call object —
a different object. So a handler registered inside a function is filed under the
call object, which a door's EvalOnExit(js_scope) never looks at → it silently
never runs:
function main() {
g_game = ...;
js.on_exit("flush()");
}
main();
var g_game = null;
function flush() { if (g_game) { } }
function main() { g_game = ...; }
if (typeof MYMOD_NO_MAIN == 'undefined') {
js.on_exit("flush()");
main();
}
Top-level var / function declarations are side-effect-free, so this stays
compatible with the *_NO_MAIN headless-syntax-check pattern (only the main()
call is gated). Keep the handler and its state at top level; wire the state
from inside main() before exit.
Prefer try/finally for clean-unwind cleanup; use on_exit only as the forced-terminate backstop
Because of the scope trap above — and because a door disconnect is a clean
unwind, not a forced kill (carrier drop → console.getstr returns → your code
returns normally) — the reliable place for "save on the way out" in a door is a
try { … } finally { … } around your main loop. finally is in scope (it
sees your locals directly), runs on every clean exit (disconnect, idle-hangup,
normal return, exit(), thrown exception), and sidesteps the on_exit scope
subtleties entirely. Reserve js.on_exit (registered at top level) for the one
case finally misses: an operator-terminate / recycle (forced, uncatchable).
Belt-and-suspenders: do the real work in finally, and keep a top-level
on_exit that calls the same handler for the forced case.
Testing caveat: an in-process test that calls your functions directly never
exercises on_exit (it only fires at real termination) — and a jsexec
wrapper does not reproduce a door's on_exit scope, because jsexec evaluates
against the global and recurses child scopes while a door evaluates against the
child js_scope and does not. So a green jsexec on_exit test can still be a
door no-op. Treat finally as the dependable mechanism and verify door-only
behavior on an actual node.
load() and require()
load() (and the closely-related require(), added v3.17) compiles and runs an
external script from within a parent script — most often to pull in constants
and object libraries from exec/load/ (e.g. sbbsdefs.js, text.js,
graphic.js). Three forms:
var result = load('myscript.js', 1, 2, 3);
var queue = load(true, 'child.js', 1, 2, 3);
var lib = load({}, 'mylib.js');
lib.do_thing();
Library files meant to be load({}, …)-ed end with a bare this; statement so
their definitions land in the passed object — this isolates them from the parent
scope (no name collisions). load()'s search path is jsexec's -i (default
load).
⚠️ load('file') runs in the caller's scope — capture what other scopes need
The default form load('file.js') (no object/thread arg) runs the file in the
calling function's scope, so the file's top-level vars and functions become
locals of whatever function called load() — not globals. A name loaded
inside one function is invisible to sibling functions and to top-level handlers.
This bites the js.on_exit pattern especially (see the lifecycle section): a
top-level handler — or any top-level function — cannot see a value load()ed
inside main().
function flush() { quetzal.write(...); }
function main() {
load('quetzal.js');
js.on_exit('flush()');
}
var quetzal_ref = null;
function flush() { if (quetzal_ref) quetzal_ref.write(...); }
function main() {
load('quetzal.js');
quetzal_ref = quetzal;
}
The same applies to any load()ed library used from a sibling scope (e.g.
load('http.js') → capture HTTPRequest). The failure is silent inside a
try{}catch{} (a swallowed ReferenceError), so a feature can look "done" yet
never run — verify on the real target (a live door), not just jsexec, where
everything is global. (Same root cause as the on_exit scope trap above.)
Constants: C vs JS namespaces (USER_UTF8 ≠ UTF8)
C/C++ headers and the JS-side constant files don't share names. Same values,
different prefixes:
C/C++ (sbbsdefs.h, scfgdefs.h) | JS (exec/load/*.js) |
|---|
UTF8, NO_EXASCII, ANSI | USER_UTF8, USER_NO_EXASCII, USER_ANSI |
K_UTF8, K_NOCRLF | K_UTF8, K_NOCRLF (same) |
P_UTF8, P_NONE | P_UTF8, P_NONE (same) |
User-related terminal/setting flags get a USER_ prefix on the JS side to
avoid polluting the JS global namespace (UTF8 as a bare word would collide
too easily). Input/print mode flags (K_*, P_*) keep their prefix on both
sides since the prefix is already a namespace.
Constants live in three places, and the load behavior differs:
-
Host-injected globals — defined in C++ (js_global.cpp), added to
the global object at runtime init. Always available, no load:
LOG_EMERG, LOG_ALERT, LOG_CRIT, LOG_ERR/LOG_ERROR,
LOG_WARNING, LOG_NOTICE, LOG_INFO, LOG_DEBUG
INVALID_SOCKET
-
Class-static properties — attached to the JS class itself via
JS_DefineObject on the constructor. Always available wherever the
class is:
FileBase.DETAIL.*, FileBase.SORT.* (js_filebase.cpp)
CryptContext.ALGO_* (and similar) (js_cryptcon.c)
- Reach them like
FileBase.SORT.NAME_AZ, no load needed
-
JS-library constants — defined in exec/load/*.js files. Must be
pulled in with load() or require():
sbbsdefs.js — K_*, P_*, MSG_*, SS_*, NODE_*, etc.
userdefs.js — USER_* flags (terminal caps, settings bitfield)
sockdefs.js — SOCK_STREAM, AF_INET, etc.
require("sbbsdefs.js", "K_NOCRLF");
require("userdefs.js", "USER_UTF8");
load("sbbsdefs.js"); load("userdefs.js");
Defensive idiom for code that might run without the require (seen throughout
the stock scripts):
var supports_utf8 = (typeof(USER_UTF8) != "undefined")
? console.term_supports(USER_UTF8) : false;
Rule of thumb: if you can grep the JS-side constant in exec/load/*.js,
you need to load that file. If it's defined only in C/C++ headers, check
whether the host injected it (LOG_*, INVALID_SOCKET) or attached it to a
class (FileBase.DETAIL, CryptContext.ALGO) — those work without load.
Terminal capability: runtime vs stored
Two different ways to ask "does this user have UTF-8":
console.term_supports(USER_UTF8) — runtime: what the live connection
actually negotiated. Authoritative at session time.
user.settings & USER_UTF8 — stored: the user's saved preference
(set via user_terminal.js / user_info_prompts.js).
These can disagree (user toggled their pref but the connection still reports
old caps, or they connected from a different terminal). For "what can I
render right now" decisions, use term_supports(). For "what does this user
normally want" (e.g. when serializing a message that may be read later), use
user.settings.
console is only available in a real BBS session — not in jsexec, and not
always in early-startup contexts. Guard accordingly. (For testing, that absence
is exploitable: a jsexec test can define a global console stub to unit-test
terminal-facing libraries — see "Unit-testing console-dependent libraries"
under the tests section.)
MsgBase — the message-base API
MsgBase opens local mail ("mail"), a sub-board (by internal code, e.g.
"syncdata.synchronet"), or an arbitrary SMB by path. Open mail/sub by code, or
pass is_path=true to treat the string as a filesystem base path:
var mb = new MsgBase("mail");
var mb = new MsgBase("general");
var mb = new MsgBase(path, true);
if (!mb.open())
throw new Error("open: " + mb.last_error);
Core methods: open, close, get_all_msg_headers, get_msg_header,
put_msg_header, get_msg_body, get_msg_index, save_msg, remove_msg,
vote_msg, how_user_voted. last_error carries the reason after any failure.
get_all_msg_headers([include_votes=false] [, expand_fields=true])
Returns a plain object keyed by message number (string keys), each value a
header object. Iterate with for (var n in hdrs). for..in yields the keys in
ascending message-number order, so the newest mail is at the end.
var hdrs = mb.get_all_msg_headers(false, false);
for (var n in hdrs) {
var h = hdrs[n];
if (typeof h != "object" || h == null) continue;
}
include_votes=true also returns vote/poll messages (default skips them).
expand_fields controls expansion of a few derived fields (e.g.
reverse_path from the SMTP reverse-path hfield). It does not govern the
lazy-field bug below.
⚠️ The lazy-field gotcha (read this before filtering on to_ext/from_ext/etc.)
Header objects from get_all_msg_headers() resolve most string fields
lazily (a SpiderMonkey resolve hook, LAZY_STRING_TRUNCSP_NULL in
js_msgbase.cpp). The *_NULL-defaulted fields —
to_ext, from_ext, to_list, cc_list, summary, tags, from_org,
replyto, replyto_ext, replyto_list, reverse_path, forward_path,
editor, mime_version, content_type, ftn_msgid/ftn_*, etc.
— return undefined on the FIRST property access of a header object if that
*_NULL field is the first thing you touch. Touching any non-NULL field
first (number, attr, to, from, subject, when_imported, …) "primes"
the object and then every field — including the *_NULL ones — reads correctly.
Always read a non-NULL field before any *_NULL field. Filtering a mailbox
by recipient is the classic trap, because to_ext is naturally the first thing
you reach for:
for (var n in hdrs) { if (hdrs[n].to_ext == 1) count++; }
for (var n in hdrs) {
var h = hdrs[n];
var to = h.to;
if (h.to_ext == 1) count++;
}
Notes from hard experience:
- The failure is silent: you get
undefined (or, in older builds, a value
from another header), not an error. A recipient filter just quietly matches
nothing.
- It is data-dependent, not just scale-dependent. On synthetic bases it
doesn't reproduce (verified clean: uniform, varying, NULL-interleaved, net-type
and header-rich messages, up to 15000 msgs — the same scale as the live
base — with/without forced GC). On a real
mail base it bites almost every header: verified on a live ~14.4k-message base,
cold first-access of
to_ext returned undefined for ~14.26k of 14.43k
headers, while priming with .to first returned the correct value for all
~9.1k messages actually addressed to the user. The breakage starts at a clear
transition point — cold reads are correct until the first header with a
NULL/empty to_ext (a non-net/local message amid net mail) appears, after
which every subsequent header reads undefined cold for the rest of the
enumeration. A minimal self-contained reproducer hasn't been pinned — so you
cannot rely on a quick interactive test to reveal it.
- The underlying cause is a SpiderMonkey 1.8.5 engine bug sitting between
the JS engine and the
*_NULL-defaulted lazy-resolve hook in
js_msgbase.cpp. An updated root-cause diagnosis is being tracked in
GitLab issue #1143; treat the precise mechanism as unsettled and the
named "fix" commits in that issue as not yet definitive. The
touch-non-NULL-first rule is the dependable workaround regardless of which
build/commit you're on — treat it as mandatory.
- Element-access escape hatch: empirically,
hdr["to_ext"] (the bracket /
element-access form) reads correctly even on a cold header where
hdr.to_ext (dot / property-access form) returns undefined. If you can't
reorder accesses (e.g. you're filtering inside a library you don't own), the
hdr["FIELD"] form is an option. Still prefer touch-non-NULL-first when
you control the code — it's the more obvious idiom.
get_msg_header() (single fetch) doesn't exhibit it in practice, because
callers organically touch a non-NULL field next. The bug is specific to
bulk-fetched headers whose first touched property is a *_NULL field.
Field types & values
to_ext/from_ext are recipient/sender user numbers as strings — the
sysop is "1". They are clean numeric strings ("1" is one char, code 0x31);
h.to_ext == 1 is a valid loose comparison once the field is primed (see
above). If a comparison against 1 mysteriously fails, suspect the lazy-field
gotcha, not a stray byte in the data.
to/from/subject are always present (non-NULL lazy fields), so they're
safe to read first and make ideal "primer" accesses.
- Timestamps (
when_written_time, when_imported_time) are Unix time; format
with system.timestr(t).
Deleting messages
remove_msg([by_offset=false,] number_or_offset | id_string) sets the
MSG_DELETE attribute and updates the header — it flags, it does not
physically purge. Flagged messages are recoverable with fixsmb -undelete
until the base is next packed (the mail base auto-packs during nightly
maintenance, which makes deletions permanent).
for (var n in hdrs) {
var h = hdrs[n];
var to = h.to;
if (h.to_ext == 1 && /ERROR occurred/.test(h.subject || ""))
mb.remove_msg(false, Number(n));
}
⚠️ Pass the number, not the header object. remove_msg(h) (handing it a
header from get_all_msg_headers/get_msg_header) does not delete — it
silently returns false with an empty last_error, because the arg must be a
number/offset/ID. Use remove_msg(h.number) (or remove_msg(false, Number(n))).
Note remove_msg works off the header/index — you do not need to load
message bodies to delete. Read bodies (get_msg_body) only when your delete
criterion depends on body content (e.g. distinguishing two errors that share a
subject).
smbutil's d/D flag/delete all messages — for selective deletion a
MsgBase script is the right tool (see smbutils for the storage
layer and when to prefer each).
Reading text files: readAll()/readln() split lines at 512 bytes
File.readAll([maxlen=512]) and File.readln([maxlen=512]) are not "read
the whole line" — both cap at 512 bytes by default (js_file.cpp,
js_readln: int32 len = 512). readAll is just a loop over readln
(js_readall), and readln is fgets(buf, len+1, fp), which stops at
whichever comes first: the newline, or maxlen bytes.
⚠️ It splits, it does not truncate. fgets leaves the rest of an over-long
line in the stream, so the remainder comes back as the next array element /
the next readln() call — the array silently desynchronizes from the file's
actual lines. Nothing throws and f.error stays 0.
A file holding one 2000-byte line plus one 5-byte line:
| Call | Result |
|---|
f.readAll() | 5 elements: 512, 512, 512, 464, 5 |
f.readAll(65536) | 2 elements: 2000, 5 |
Symptoms: JSON.parse throwing on "some" lines (fragments aren't valid JSON);
a scan for the longest line reporting exactly 512; record counts that come out
too high; a bug that only shows up on some input files — any file whose lines
all fit in 512 bytes behaves perfectly.
Fixes, in order of preference:
.jsonl/.ndjson: use the stock library — load('json_lines.js') gives
get(filename, num, max_line_len, recover), which already does readAll plus a
per-line JSON.parse in a try/catch. ⚠️ Pass max_line_len explicitly:
its own default is 4096, which still fragments anything larger.
- Streaming:
while ((line = f.readln(65536)) !== null) — preferred for
100KB+ lines, since it avoids holding an array of giant strings at once.
- Whole file:
f.read() with no argument reads from the current
position to EOF ([maxlen=file_length-file_position]); split on /\r?\n/
yourself. No line cap at all, at the cost of the whole file in memory.
There is no "unbounded" value for maxlen — you must pass a number larger than
the longest line you expect.
Reading INI files (and the trailing-comment trap)
File.iniGetValue(section, key, default) and File.iniGetObject(section) read
Synchronet .ini config (js_file.cpp → xpdev/ini_file.c). The parser
recognizes ; as the comment char (INI_COMMENT_CHAR), but whether a trailing
; comment on a value line is stripped depends on the value's TYPE — this is
the single most common config-corruption surprise:
| Value type | Trailing key = value ; comment | Why |
|---|
| string | NOT stripped — the comment becomes part of the value | read_value/get_value take everything after =, apply only truncsp (trim trailing whitespace); a string can legitimately contain ; and spaces, so nothing is cut |
| boolean | stripped/ignored | isTrue() truncates the value at the first ;, space, or tab before matching true/yes/on |
| enum | stripped/ignored | parseEnum() keeps only the first whitespace-delimited word |
| integer / float / datetime | stripped/ignored | the numeric parse stops at the first non-numeric char |
The asymmetry is deliberate. A single-token value (bool/enum/number) can't
contain spaces, so anything after the token is safely a comment — support was
added on purpose: isTrue (cceb1fbb8, after FozzTexx reported the wiki's
sexpots.ini had true ; comment values parsing as false) and parseEnum
(7346893d6, "Enum values followed by comments are now supported"). A string
value has no such delimiter, so the parser can't guess where the value ends and
a comment begins — it keeps the whole thing.
Consequences for JS callers:
File.iniGetObject() returns every value as a raw string, so it never
strips inline comments. iniGetValue(s, k, dflt) strips only when dflt's
type routes it through the bool/number/enum path (iniGetBool/iniGetInteger/
etc.); a string default goes through iniGetString and keeps the comment.
- Don't put an inline
; comment after a string-valued key (URLs, paths,
filenames, names) — put the comment on its own line above the key. A line like
endpoint = http://host:11434/api/chat ; my note yields the literal value
http://host:11434/api/chat ; my note, which then fails wherever it's used.
- Inline comments after bool/enum/numeric keys are fine (and appear throughout
stock
.ini files) — that's exactly what the type-specific stripping is for.
The root (unnamed) section = global defaults
Keys that appear before any [section] tag live in the root (unnamed)
section. File.iniGetObject(null) reads it (and iniSetObject(null, obj)
writes it — see exec/upgrade_to_v320.js). The standard Synchronet idiom is to
put global/default key-values in the root, then let each named [section]
override them — read the root for the defaults, read the section, merge the
section on top:
var f = new File(system.ctrl_dir + 'foo.ini'); f.open('r');
var defaults = f.iniGetObject(null) || {};
var section = f.iniGetObject('guru:irc') || {};
f.close();
var cfg = {}; for (var k in defaults) cfg[k] = defaults[k];
for (k in section) cfg[k] = section[k];
Prefer the root over a named [default] section for defaults: it's what the
docs describe (https://wiki.synchro.net/config:ini_files#root_section) and it
avoids overloading a section name — a literal [default] section collides with
any value that's also used as a section key elsewhere (e.g. a sysop-configured
code that happens to be "default"). Section names are case-insensitive, so a
value used as a section key should be case-folded before it lands in a filename
to avoid case-variant dupes on case-insensitive filesystems.
Common API recipes
print(JSON.stringify(new User(1), null, 2));
var u = new User(1); for (var k in u) print(k);
load("sbbsdefs.js"); print(USER_DELETED.toString(16));
Sysop-configurable text strings (bbs.text) — and the multi-%s format trap
When your module emits a string that a stock prompt/message also emits, don't
hand-roll the wording and colors — pull the sysop-configurable text.dat
string and format() it, so your output honors the sysop's theme and
localization. bbs.text(id) returns a text[] entry; id may be the numeric
index OR the string identifier (resolved via text_id_map), so
bbs.text("NodeMsgFmt") works with no load("text.js") needed:
var header = bbs.text("NodeMsgFmt");
var msg = format(header, bbs.node_num, user.alias, body);
system.put_node_message(target_node, msg);
⚠️ A text.dat format string can carry MORE %-specifiers than the one or two
you expect — many are multi-line (a trailing \ continues the string in
text.dat) and include a body %s. NodeMsgFmt is
"\7\1_\1w\1hNode %2d: \1U%s\1u sent you a message:\r\n\1w\1h\x014%s\1n\r\n" —
three specifiers: node number, sender, and the message body (already
wrapped in its own color + CRLF). You must supply all of them in the format()
call. Passing only the first two and appending your body is a real,
shipped-bug pattern: the body %s renders literally as %s and your text
lands on a stray extra line. Read the actual string (it's in ctrl/text.dat,
keyed by the id) and count the specifiers before formatting — don't assume from
the name. (format() only interprets specifiers in the format string; % in a
substituted arg is safe/literal.)
ARS access checks: bbs.compare_ars (current user) vs User.compare_ars (anyone)
To test an access-requirement string, there are two entry points and picking the
wrong one is easy:
bbs.compare_ars(ars) — evaluates against the current session user.
Returns true for null/""/undefined (no requirement = everyone passes).
User.compare_ars(ars) — the User object method, so you can test
any user, not just the one online. Construct the user and ask:
var u = new User(node.useron);
if (!ars || u.compare_ars(ars)) { }
A program section's requirement string is xtrn_area.prog[code].execution_ars
(run requirement) / .ars (visibility). Enumerate live nodes via
system.node_list[] (0-based array; node number is index + 1); a node is a
real, in-use session when nd.status == NODE_INUSE (skip WFC/quiet/logon) and
nd.useron is set. (NODE_* come from sbbsdefs.js.)
Output & input: choosing the right function
Two families, and the difference matters:
- Global
write(), writeln() (aka print()), printf(), write_raw(),
alert(), log() — work under both the Terminal Server and jsexec.
Most translate Ctrl-A codes (write_raw()/putbyte don't). When no user is
online, write()→log(LOG_INFO) and alert()→log(LOG_WARNING).
console.* (console.print, console.putmsg, console.center,
console.getstr, console.getkey, console.yesno, …) — Terminal Server
only (a user session); not bound under jsexec. console.print/putmsg
also expand @-codes (and putmsg honors menu attributes); getstr/etc.
do line-counting and prompting a bare read()/readln() won't.
So in a jsexec probe or service test, use print()/writeln()/log() — never
console.* (it throws ReferenceError off-session). In a Terminal Server
module that displays menu/text files, use console.putmsg().
Carriage returns / PETSCII: don't emit a raw \r to move the cursor to
column 0 — use console.creturn() or the Ctrl-A [ sequence. On PETSCII
terminals an ASCII 13 performs a full newline (\r\n), so a raw \r breaks
cursor positioning. Likewise, emit a line break with console.newline(), not
console.print("\r\n") — the purpose-built call does the terminal-correct thing
(and reads clearer). Same principle as creturn(): reach for the named console
method over a hand-written control string.
Terminal control sequences are abstracted in ansiterm_lib.js — check before hardcoding ANSI
Before hand-writing a raw ANSI/cterm escape (\x1b[?25l to hide the cursor,
cursor moves, scroll regions, ext-modes), look at exec/load/ansiterm_lib.js
— it builds them by name. load({}, 'ansiterm_lib.js') returns an object whose
send(group, op, arg) constructs and console.writes the sequence:
var ansiterm = load({}, 'ansiterm_lib.js');
ansiterm.send("ext_mode", "clear", "cursor");
ansiterm.send("ext_mode", "set", "cursor");
ansiterm.send("cursor_position", "move", "up", 3);
ext_mode covers the CSI ?<n>h/l private modes — cursor (25), autowrap
(7), origin, etc. — with set/clear/save/restore; there are also
cursor_position and colour/attribute groups. cterm_lib.js is the canonical
consumer (ansiterm.send("ext_mode", "clear", "cursor")). The bare-25-style
codes live in its defs table, so you never memorize the numbers.
Two caveats: send() writes a LOG_DEBUG line every call (noisy in a hot
loop like a per-frame cursor toggle), and it always console.writes — there
is no "return the string" form, so if you need the bytes without emitting (to
batch into one write), call the builder directly: ansiterm.ext_mode.clear('cursor')
returns the string. Consistency exception: a module that already hardcodes a
whole family of related raw escapes for its own reasons — e.g. the v6 zmachine
door's \x1b[?69h…s DECSLRM + \x1b[t;b r DECSTBM scroll-region writes in
setRegion/resetRegion — may keep adjacent controls raw to match; but for a
new or one-off control, reach for ansiterm_lib rather than reinventing the code.
Timed / non-blocking key input, and the inactivity model
There are two ways to read a key, and they differ in a way that bites: only
the blocking pair enforces idle-disconnect.
console.getstr() / console.getkey() block, and run through C
getkey() (src/sbbs3/getkey.cpp), which owns inactivity enforcement:
it tracks the last-activity time, emits the AreYouThere text at the warn
threshold, and hangs up after max_getkey_inactivity seconds (default
300, scfglib1.c). So any normal prompt gets idle protection for free.
console.inkey([mode=K_NONE][, timeout_ms=0]) is the only timed /
non-blocking key read (timeout in milliseconds; 0 = poll once and
return immediately). It does NOT track activity, warn, or hang up. Its
JS return on timeout is mode-dependent (verified in inkey.cpp +
js_console.cpp, with NOINP == 0x0100):
- default
K_NONE → returns "" (empty string) on timeout (C inkey
returns 0; js builds a zero-length string). A real NUL key (code 0) also
reads as "" — rare, but ambiguous.
K_NUL (1<<25, "return NOINP on timeout instead of '\0'") → returns
null on timeout and "\0" for an actual NUL key. Use this when you
need to tell timeout from a keypress unambiguously.
- any real key → a 1-char string.
Trap: a loop that polls inkey() for timed/real-time input (animations,
countdowns, a game's timed prompt) bypasses the idle-disconnect getkey
provides — an AFK user can hold a node open indefinitely. If you build a timed
reader on inkey, enforce inactivity yourself: track elapsed time since the
last real key, compare to console.max_getkey_inactivity, optionally print the
AreYouThere text at console.getkey_inactivity_warning, then bbs.hangup()
past the limit. These are live on the console object:
| Property | Access | Meaning |
|---|
console.max_getkey_inactivity | read/write | idle limit in seconds (default 300) |
console.getkey_inactivity_warning | read-only | warn threshold in seconds (derived from inactivity_warn %) |
console.last_getkey_activity | read/write | Unix time of last getkey activity — writable, so a timed loop can keep it in sync with real keypresses |
Function / cursor keys: K_CTRLKEYS (parse yourself) vs K_EXTKEYS (translated, and lossy)
By default inkey/getkey pre-translate the terminal's arrow / Home / End
escape sequences into control codes (sbbsdefs.h TERM_KEY_*): Right → CTRL_F,
Home → CTRL_B, End → CTRL_E, Up → CTRL_^, Down → CTRL_J, Left → CTRL_]. So an
arrow becomes indistinguishable from the matching Ctrl-key — a real conflict if you
also bind Ctrl-letters (a door mapping Ctrl-F → an F-key, a WordStar-ish editor, etc.).
Two mode flags govern this (inkey.cpp):
K_EXTKEYS (1<<30) — passes control keys through and runs
term->parse_input_sequence() on an ESC. But that still returns the conflated
control codes for arrows, and parse_input_sequence(char& ch, …) returns a single
char — there are no TERM_KEY_F1..F12, so function keys aren't represented. So
K_EXTKEYS does not disambiguate arrows from Ctrl-letters and loses the F-keys.
K_CTRLKEYS (1<<24, "no control-key handling/eating") — control keys pass
through and the ESC-translation branch does not run (it's gated on K_EXTKEYS).
So the arrow/function escape sequences arrive RAW (ESC[C, ESC[15~, …) for you
to parse — keeping real arrows/F-keys distinct from the actual Ctrl-letters.
To fully support function + cursor keys, read with K_CTRLKEYS and parse the cterm
sequences yourself. SyncTERM/cterm send (conio/cterm.txt): arrows ESC[A/B/D/C
(up/down/left/right); F-keys ESC[11~..15~ (F1–F5), ESC[17~..21~ (F6–F10),
ESC[23~/24~ (F11/F12); plus SS3 ESC O P/Q/R/S (F1–F4) on some terminals.
require("sbbsdefs.js", "K_CTRLKEYS");
function keyByte(ms) {
var k = (ms === undefined) ? console.getkey(K_CTRLKEYS)
: console.inkey(K_CTRLKEYS, ms);
return (k && k.length) ? k.charCodeAt(0) : -1;
}
Raw-byte alternative: console.getbyte([ms]) reads straight from incom(), bypassing
all translation — but it also bypasses getkey's idle-disconnect (inkey/incom
don't enforce it — see above) and inkey's UTF-8/charset decoding, which you'd re-add.
Prefer K_CTRLKEYS (you stay on getkey/inkey) unless you genuinely need byte-level control.
Numbered menus (console.uselect) and the auto-pager
console.uselect builds a numbered selection menu: one call per item, then a
final call to display it and read the choice.
for (var i = 0; i < items.length; i++)
console.uselect(i, "a Game", items[i].label);
var sel = console.uselect(defaultIndex);
Two non-obvious behaviors (from con_hi.cpp / js_console.cpp):
- The title is auto-prefixed with "Select " (
text.dat SelectItemHdr =
"Select %s"). Pass "a Game" → renders Select a Game:; passing
"Select a Game" renders the doubled Select Select a Game:.
- The display call's number argument is the DEFAULT item — the
[N] shown and
the value returned on ENTER. Pass the index to pre-select (e.g. the last-used
item): console.uselect(lastIndex). No-arg defaults to item 0.
Auto-pager ([Hit a key] / [MORE]): Synchronet auto-pauses when
console.line_counter reaches the screen height — the check (check_pause(),
con_out.cpp) runs after every character, gated by pause_enabled() (the
user's UPAUSE/SS_PAUSEON, unless SS_PAUSEOFF). This is independent of any
pagination your own code does (it fires even if you never call console.pause),
and it bites two ways:
console.clear() pauses before it clears. The signature is
console.clear([attr] [, autopause=true]) (js_console.cpp) — autopause
defaults to true, so when the line counter is high (a screenful of unread
output) console.clear() shows [Hit a key] before wiping the screen. A loop
that prints then clears each turn — e.g. a full-screen door redrawing per move —
therefore pauses on every clear. The cleanest fix is to clear with autopause
off: console.clear(attr, false). Prefer this over resetting the counter
first (below): any output between your reset and the clear — e.g. repainting a
pinned status bar — re-inflates line_counter, so a pre-clear reset is fragile.
- Stray mid-output pause. After printing a few lines and then clearing — e.g.
a one-time notice before a menu — the pending count can trip a pause. Reset it
before the output/clear:
console.line_counter = 0;
console.clear(attr, false);
(Per-call: pass the P_NOPAUSE mode flag to console.print/putmsg to suppress
the pause for that write. If your code paginates its own way, turn the built-in
pause off at the output sites rather than fighting line_counter.) Hard-won:
chasing a door's "extra [Hit a key] before every screen clear" through
morePause/line_counter resets is a dead end — it's console.clear()'s own
default autopause.
console.printfile(path) displays a message/ANSI file (intro splashes, help
screens) — it renders Synchronet Ctrl-A codes and ANSI/CP437 as-is, so no mode
flag is needed for the usual .msg/.ans. Add P_PCBOARD only if the file
uses PCBoard @X color codes (otherwise it would misread a literal @). Follow
with console.pause() if the next thing clears the screen.
Don't pass P_NOPAUSE reflexively. With default flags printfile paginates a
longer-than-a-screen file via line_counter (the right behavior for help/info
screens). P_NOPAUSE suppresses that — correct only when something else
provides the pause (e.g. a splash immediately followed by a menu or a screen
clear). For a standalone splash, omit it and add your own console.pause() for
the deliberate "read this" beat. Copying P_NOPAUSE from a nearby printfile
call whose surrounding context justified it — without re-checking that the reason
still holds — is a common mistake (it silently disables paging for everyone whose
file later grows past one screen).
The stock exec/*.js ecosystem (API by example)
~100 stock .js modules ship in exec/; reading them is usually the fastest
way to learn an API, and many double as ready-made tools. Grep for new X( or
X.method to find a usage example before writing your own.
MsgBase / FidoNet: msgutil.js (swiss-army MsgBase tool), postmsg.js,
sendmsg.js, scrubmsgs.js, delmsgs.js, dupefind.js, ftnmsgdump.js,
binkit.js (also a Socket reference), echoareas.js.
FileBase: addfiles.js, delfiles.js, filelist.js, fileman.js,
purgefiles.js, rehashfiles.js, hashfile.js.
Users: allusers.js (user-walk template), badpasswords.js,
inactive_user_email.js, last10logins.js, makeuser.js.
Config: exportcfg.js / importcfg.js (SCFG ↔ JSON), make_areas_ini.js.
Net/mail/IRC: wget.js (HTTPRequest reference), letsyncrypt.js,
certtool.js, sendmail.js, ircbot.js, mqtt_pub.js/mqtt_sub.js.
LLM chat / Guru: chat_llm.js (LLM-backed Guru chat engine; entry
chat_session(input, ctx) / open_session(ctx), driven by a Guru's SCFG
Module field), chat_llm_irc.js (IRC bot adapter), llm_tools.js +
llm_tools/*.js (drop-in function-calling tools), llm_index.js +
llm_index/*.js (BM25 RAG indexer). See the wiki links below.
Inspection: dumpini.js, hexdump.js, sauce.js, jsdocs.js,
syncjslint.js, sockinfo.js.
Services (also runnable standalone for testing): nntpservice.js,
imapservice.js, gopherservice.js, websocketservice.js, json-service.js.
cd "<sbbs>/exec" && for f in *.js; do grep -qE '\b(bbs|console)\.' "$f" || echo "$f"; done
Writing tests in exec/tests/
Synchronet ships a tiny harness at exec/tests/test.js (jsexec-only; no
separate runner). Layout: exec/tests/<category>/<name>.js; test.js walks the
tree depth-first and runs every .js.
- Pass = runs to completion without throwing. Fail = throws,
exit(nonzero), or returns an instanceof Error. No assert() framework —
just throw new Error("descriptive message").
skipif file in a category dir is load()-ed; truthy result skips the
whole category. E.g. exec/tests/msgbase/skipif → typeof MsgBase === 'undefined'.
- No setup/teardown hooks — each file does its own; wrap scratch state in
try/finally.
Temp-msgbase test pattern (so you never touch the install's mail):
var path = system.temp_dir + "test_foo_" + Date.now();
var EXT = [".shd", ".sdt", ".sid", ".sha", ".sda", ".ini", ".hash"];
function cleanup() { EXT.forEach(function (e) { try { file_remove(path + e); } catch (x) {} }); }
try {
var mb = new MsgBase(path, true);
if (!mb.open()) throw new Error("open: " + mb.last_error);
if (!mb.save_msg({ to:"x", to_ext:"1", from:"y", subject:"z" }, "body"))
throw new Error("save_msg: " + mb.last_error);
mb.close();
} finally { cleanup(); }
Caveat for regression tests of the lazy-field gotcha: a single-message (or
all-uniform, or purely synthetic) base does not reproduce it — that's
exactly why ca448cb8b's test passes while the bug persists on real bases. A
meaningful regression test must reproduce the real-base conditions (it remains
an open item to pin a minimal synthetic reproducer); until then, treat the
"touch a non-NULL field first" rule as the reliable mitigation.
Syntax-checking / testing a module that acts at load time
Some modules do work the moment they're loaded — start a server loop,
connect a socket, emit a greeting (e.g. chat_llm_irc.js connects to IRC
and runs its main loop at the bottom of the file). You can't just
load() such a file to syntax-check it or exercise its helpers, because
load() runs it — connecting a second bot / launching the loop.
The idiom: gate the entry point behind a sentinel the loader can set, then
load() with the sentinel set. load() compiles the whole file (so a
real syntax error throws) but skips the side effect:
if (typeof MYMOD_NO_MAIN == 'undefined') {
main_loop();
}
var MYMOD_NO_MAIN = true;
load('mymod.js');
print(typeof some_helper);
Stock examples: chat_llm.js's CHAT_LLM_NO_STANDALONE, chat_llm_irc.js's
CHAT_LLM_IRC_NO_MAIN. This guard-and-load() is the reliable way to
syntax-check a side-effecting module without running it.
Unit-testing console-dependent libraries under jsexec (stub the console)
console being undefined under jsexec cuts both ways: you can't run
terminal code there directly, but a jsexec test can define a global
var console = {...} stub and unit-test a library that talks to the
terminal — simulating the remote terminal's side of the conversation. This
makes otherwise "needs a live terminal" logic (query/response capability
probes, escape-sequence emission, response caching) repeatable and assertable:
- Feed responses:
write(s) records everything "sent" into a wire
string and, when s matches a known query, queues the canned reply's chars;
inkey(mode, timeout) returns queued chars one at a time and "" when
empty (= timeout). Add whatever properties the library touches
(ctrlkey_passthru, screen_rows, status, ...).
- Assert on the wire: claims like "query sent exactly once" (caching) or
"never sent to a non-answering terminal" fall out of substring counts on
wire.
- Reset between scenarios: libraries stash session state on
console
(cterm_lib.js stashes cterm_version, cterm_font_state, cterm_da,
...) — delete those properties and load({}, lib) a fresh copy per
scenario.
var wire = "", input = [], responses = {};
var console = {
ctrlkey_passthru: 0, screen_rows: 24, screen_columns: 80, status: 0,
write: function(s) {
wire += s;
if (responses[s] !== undefined)
for (var i = 0; i < responses[s].length; i++)
input.push(responses[s].charAt(i));
},
inkey: function(mode, timeout) { return input.length ? input.shift() : ""; },
clearkeybuffer: function() { input = []; }
};
console.cterm_version = 1330;
responses["\x1b[<c"] = "\x1b[<0;1;2;3;4;5;6;7c";
var cterm = load({}, "cterm_lib.js");
if (cterm.supports_palettes() !== true) throw new Error("palette probe failed");
if (wire.split("\x1b[<c").length - 1 !== 1) throw new Error("CTDA queried more than once");
Proven use: cterm_lib.js's CTDA-based capability checks (fonts / palettes /
sixel / bright-background) were validated exactly this way — six simulated
terminals (non-CTerm silence, graphics-mode, text-mode, pre-CTDA version,
prior-setfont results) with no live SyncTERM in the loop. Caveat: the stub
proves the library's logic against the documented wire behavior; when the
protocol itself is in doubt, verify the terminal's real responses first
(src/conio/cterm*.c / cterm.adoc, or a live session).
exec/syncjslint.js is a style linter (jslint), not a syntax gate:
it emits false positives on valid SpiderMonkey constructs (e.g. a - inside
a regex character class → "Unexpected '-'") and halts on them. Use it for
style review, not as a compile / pass-fail check.
system.temp_dir + name + Date.now() is unique per run; the seven extensions
above are the full set an SMB can produce.
References
The authoritative resources, as gathered on and linked from the wiki's
JavaScript hub (https://wiki.synchro.net/custom:javascript):
Synchronet JavaScript:
Core JavaScript language (the engine speaks this):
To update any of these wiki pages, see the synchronet-wiki skill.
Cross-references
- Run JS / jsexec flags / Windows debug builds →
jsexec.
- SMB storage-layer repair (smbutil/chksmb/fixsmb) →
smbutils.
- Build sbbs.dll / a debug binary →
synchronet-build.
- Display/menu files, @-codes, Ctrl-A →
menus.