FTS in Node.js: CLI utility and HTTP boundary
Model .fts handles the rule. Everything else — sockets, routes, response codes, request body limit — is regular Node.js code. In this chapter, the boundary runs along a real course file: examples/fts/discount-api/server.mjs, an HTTP service on a built-in node:http without a framework, which is started with the npm run fts:api command.
Minimal example
категория «Продажи»
объект Покупка
сумма является деньгами
«постоянный клиент» является признаком
утилита «Рассчитать скидку»
принимает Покупка
возвращает деньги
начинает с 0
правило «Большая покупка»
если сумма не меньше 10000
и сумма не больше 100000
то добавить 10 процентов от поля сумма
правило «Постоянный клиент»
если «постоянный клиент» равен да
и сумма больше 0
и сумма не больше 100000
то добавить 5 процентов от поля сумма
правило «Очень крупная покупка»
если сумма больше 100000
то добавить 15000
свойство «Скидка ограничена»
результат не больше 15000
пример «Обычная покупка»
дано сумма равна 5000
дано «постоянный клиент» равен нет
ожидается результат равен 0
пример «Постоянный клиент на пять тысяч»
дано сумма равна 5000
дано «постоянный клиент» равен да
ожидается результат равен 250
пример «Большая покупка постоянного клиента»
дано сумма равна 20000
дано «постоянный клиент» равен да
ожидается результат равен 3000
пример «Покупка на потолок скидки»
дано сумма равна 100000
дано «постоянный клиент» равен да
ожидается результат равен 15000
пример «Очень крупная покупка»
дано сумма равна 200000
дано «постоянный клиент» равен нет
ожидается результат равен 15000
This exact file is located in static/fts/models/order-discount.fts and it is this one
that server.mjs loads. One model — it is read by the sandbox on the site, the HTTP service
and its tests.
What the compiler does at the service startup
server.mjs compiles the model once when starting the process, not for each
request:
import { assertValid, compile, executeUtility, testUtilities } from '../../../static/js/vendor/fts/browser.js';
export async function createCalculator(modelFile = MODEL_FILE) {
const document = assertValid(compile(await readFile(modelFile, 'utf8')));
const tests = testUtilities(document);
if (!tests.valid) {
const failed = tests.results.filter((result) => !result.passed);
throw new Error(
`FTS-примеры не прошли (${failed.length} из ${tests.total}): ` +
failed.map((result) => `«${result.example}» ожидалось ${result.expected}, получено ${result.actual}`).join('; '),
);
}
return { document, tests, calculate: (purchase) => executeUtility(document, UTILITY, purchase) };
}
assertValid throws an exception if validate finds a structural error —
the service won’t start on an invalid model. testUtilities checks all
examples and also prevents startup if any one diverges from the rule: this
duplicates the idea from the previous chapter at the process level, not just CI. Next
calculate — a regular synchronous function that can be triggered from any
handler without recompilation.
In the course repository, the compiler is taken from the vendor copy
static/js/vendor/fts/browser.js — the same one that runs the
sandbox on the website. In your project, you will install the @digitable-lol/fts package and
import compile, executeUtility, validate from it directly;
the API is the same.
HTTP handler and contract
if (request.method === 'GET' && request.url === '/contract') {
return send(response, 200, { utility: UTILITY, input: fields, output: 'Деньги' });
}
if (request.method !== 'POST' || request.url !== '/discount') {
return send(response, 404, { error: 'используйте POST /discount' });
}
const purchase = JSON.parse(await readBody(request));
return send(response, 200, { discount: calculate(purchase) });
fields is taken not from a manual description in the code, but directly from the compiled
model: document.structures.find((s) => s.name === 'Покупка').fields.
The endpoint GET /contract returns the current list of input fields and output type —
if the model changes, the contract will change along with it, without a second place
that needs to be updated without fail. Check locally:
node examples/fts/discount-api/server.mjs
curl -s localhost:8788/discount -d '{"сумма":20000,"постоянный клиент":true}'
# {"discount": 3000}
curl -s localhost:8788/contract
curl -s localhost:8788/health
FTS does not read request and does not write response — calculate accepts
a flat object and returns a number or throws an exception. Therefore, the utility
itself can be tested without a single HTTP call, which is what fts test from the previous chapter does.
Why login error is 400, not 500
} catch (error) {
return send(response, 400, {
error: error instanceof Error ? error.message : String(error),
diagnostics: error?.diagnostics ?? [],
});
}
executeUtility throws a structured error with field diagnostics if the input field does not match the type (FTS_UTILITY_INPUT_TYPE), a required field is missing, or a property (FTS_UTILITY_PROPERTY) is violated. This is a request error — the client sent data that does not conform to the contract — not a service failure. Returning 500 in this case would mean saying “we broke” where in fact “you sent the wrong thing.” server.test.mjs checks this explicitly:
test('нарушение типа входа возвращает диагностику FTS, а не 500', async () => {
const response = await fetch(`${origin}/discount`, {
method: 'POST',
body: JSON.stringify({ 'сумма': 'много' }),
});
assert.equal(response.status, 400);
const body = await response.json();
assert.equal(body.diagnostics[0].code, 'FTS_UTILITY_INPUT_TYPE');
});
400 with body diagnostics gives the client a machine-readable reason — the same code
that would return fts run in the terminal — instead of a stack trace or a general phrase
«internal error».
Generated variant instead of interpretation
If runtime interpretation is not needed and a regular function is sufficient:
fts generate static/fts/models/order-discount.fts --out src/generated
import { ftsUtilities } from "./generated/fts.utilities.js"
const calculate = ftsUtilities["Рассчитать скидку"]
const discount = calculate({ сумма: 20_000, "постоянный клиент": true })
The difference with the interpreter is not in the result — both paths go through the same semantics of rules and properties — but in that generation gives a regular TypeScript file without dependency on compile at runtime. The course demonstrates this in examples/fts/typescript-codegen: the command with the --check flag compares the written file with what the model would generate anew, and fails if the code was manually changed outside .fts.
Mistakes to Avoid
- Do not compile the custom
.ftswithout a limit on the request body size —server.mjsstops reading after 64 KB before callingJSON.parse. - Do not pass an arbitrary object to
executeUtilityas a whole: the utility needs only the declared scalar fields, and an extra field is already an errorFTS_UTILITY_INPUT_FIELD, not a silently ignored value. - Do not perform a payment or charge immediately upon the response
calculate; first obtain a verified result, then perform the effect via a separate call, which can be repeated or rolled back independently. - Do not keep a second, manual implementation of the same rule in TypeScript “just in case” without a test that compares it to the model — sooner or later they will diverge, and they will do so quietly.
Practice in the sandbox
- Open the tab
typescriptand find the functionРассчитать скидкуin the generated code. Compare its body with the rules from the tabmodel— make sure the addition order matches line by line. - Run
node examples/fts/discount-api/server.mjsandcurl -s localhost:8788/discount -d '{"сумма":"много"}'locally. Check the response code and the fielddiagnostics[0].code. - Add a new numeric field to the object
Покупкаin the model (for example,вес) and look at the tabtypescriptto see how the login interface changes without a single JSX or route/contractmodification in the server code.
Common Fallacies
FTS_UTILITY_INPUT_TYPE— field value in the input JSON does not match the declared type (e.g., a string instead of a number for a field of typeДеньги). Returned as400with bodydiagnostics, not as500— see the section above.FTS_NO_UTILITY_EXAMPLES— the same code as in the previous chapter, but here it stops not CI, but the process itself: if the last example was removed from the model,testUtilitiesincreateCalculatorwill throw an exception on start, andnpm run fts:apiwill terminate without opening the port.FTS_UTILITY_PROPERTY— the property is violated already on a real input, not in an example. The handler catches this exception along with other errorsexecuteUtilityand turns it into400with diagnostics — from the client’s perspective, this is the same response format as with an incorrect field type.
Checklist
- The model is compiled and passed through
testUtilitiesonce at the start of the process, not with each request. - The HTTP handler does not contain business conditions — only routes, body parsing and response codes.
- The input contract (
GET /contractor its equivalent) is built fromdocument.structures, not duplicated manually. executeUtilityerrors are returned to the client as4xxwith the fielddiagnostics, not converted into a general500.- There is a limit on the request body size up to
JSON.parse.