В этой статье я расскажу, как создавал скриптовый язык WiremockJs — с нуля и до рабочего прототипа. Поделюсь, что меня подтолкнуло к этому «подвигу», как я проектировал грамматику, с какими ограничениями столкнулся и почему в итоге не стал использовать JavaScript, а написал свой упрощённый диалект. Под катом — ANTLR, парсеры, немного боли и много удовольствия от творчества. Читать далее
В этой статье я расскажу, как создавал скриптовый язык WiremockJs — с нуля и до рабочего прототипа. Поделюсь, что меня подтолкнуло к этому «подвигу», как я проектировал грамматику, с какими ограничениями столкнулся и почему в итоге не стал использовать JavaScript, а написал свой упрощённый диалект. Под катом — ANTLR, парсеры, немного боли и много удовольствия от творчества.
ОглавлениеПару месяцев назад я сделал UI для WireMock. В него вошло всё, что я тогда успел:
визуальный конструктор стабов,
удобное дерево API с поиском и фильтрацией,
вызов стаба прямо из интерфейса (без Postman),
история запросов на отдельной вкладке,
сценарии и запись моков.
Добавление стабов по openApi спецификации
Но главное, что избавило меня от постоянной головной боли при переключении между проектами, — профили. Загрузил нужный профиль — и все заглушки уже настроены, не надо думать о приоритетах и совпадениях ручек. Кстати, многие UI пытаются совместить показ API и создание моков в папках, как в Postman, но я считаю такой подход не совсем удобным. Профили оказались проще и практичнее.
Второй момент – это история запросов. Было не понятно, а вызвался ли запрос? А что пришло и когда?
Третий момент и наверное самое главное это тестировщики. Почему-то с вопросами по мокам они шли именно ко мне. После нескольких дней коллективной настройки я понял, что так дальше нельзя. Во‑первых, я разработчик, а не тестировщик. Во‑вторых, я не могу бесконечно обучать новых сотрудников нюансам WireMock, MockServer и JSON. Люди приходят и уходят, а время на объяснения уходит колоссальное. Плюс я сам забываю что я там создавал и как это делал. Мне нехватало визуального конструктора. Прошелся по шагам и все готово. Не надо думать, а правильно ли ты создал json.
Вы спросите: почему не взять готовый интерфейс для WireMock? Тут причина во мне, мне нравится копаться и делать всё под себя, так, как я считаю удобным. Наверное, я просто очень придирчив к чужим программам, поэтому и решил написать своё.
Ценит ли компания меня за то, что я пытаюсь привнести автоматизацию и что-то новое? Если честно, я уже давно не задаю себе этот вопрос. Я уже писал об этом в отдельной статье (ссылка), и мой опыт за 15 лет говорит: не компания создаёт условия для разработки, а конкретные люди внутри неё. Если мне нужен инструмент, который сэкономит время, я его пилю. Если нужны деньги, то пытаюсь создавать свои собственные сервисы и платформы вне работы. Получается ли? Пока в процессе, но это уже совсем другая история.
А чтобы не выгорать на основной работе, я позволяю себе такие маленькие пет-проекты — UI или расширения для WireMock. Они приносят удовольствие, а заодно экономят время на те задачи, которые я действительно люблю.
На чём я остановился в прошлый раз?В прошлой версии UI я не успел добавить скрипты. Для автотестов хотелось задавать не просто статичные стабы, а гибкие моки с логикой. WireMock позволяет подключать расширения на Java через JAR-файлы, но такой подход казался мне слишком громоздким:
автотестировщики пишут на Python или других языках, не факт, что они будут кодить на Java;
любое изменение требует сборки, деплоя и перезапуска WireMock — это минуты, а не секунды.
Я мечтал о том, чтобы скрипт можно было править на лету и сразу видеть результат. Ну и автотестировщики получив такой инструмент напишут автотесты. Что в долгосрочной перспективе скажется положительно на разработке.
Я стал искать что есть в wiremock. И как это сделано в других сервисах для моков. Нашел WireMockCoolExtensions. Но там проект не обновлялся уже 10 лет. Да и не выглядел он как законченный продукт. Стал думать как добавить javascript в расширение, но тут всплыли проблемы:
безопасность (выполнение JS в JVM — это риск),
зависимость от Nashorn (устарел) или GraalVM Polyglot (тяжёлый),
WireMock до сих пор сидит на Java 11, и подтаскивать эти фреймворки не хотелось.
Я взвесил всё и решил: всех возможностей JavaScript для написания заглушек не нужно. Достаточно создать свой упрощённый скриптовый язык. Назвал его WiremockJs. Для построения грамматики выбрал ANTLR — мощный и проверенный генератор парсеров.
Итак, первая версия грамматики выглядела так:
Первая версия грамматикиgrammar WiremockJs;
script
: statement+ EOF
;
statement
: ifStatement
| returnStatement
;
ifStatement
: IF '(' expression ')' '{' thenStmt+=statement+ '}' (ELSE '{' elseStmt+=statement+ '}')?
;
returnStatement
: RETURN expression ';'
;
expression
: expression op=('*'|'/'|'%') expression # MulDiv
| expression op=('+'|'-') expression # AddSub
| expression op=('>'|'>='|'<'|'<=') expression # Compare
| expression op=('=='|'!=') expression # Equality
| expression AND expression # LogicalAnd
| expression OR expression # LogicalOr
| NOT expression # LogicalNot
| functionCall # FuncCallExpr
| fieldAccess # FieldAccessExpr
| literal # LiteralExpr
| '(' expression ')' # ParenExpr
;
functionCall
: IDENTIFIER '(' argumentList? ')'
;
argumentList
: expression (',' expression)*
;
fieldAccess
: IDENTIFIER ('.' IDENTIFIER)*
;
literal
: STRING
| NUMBER
| BOOLEAN
| jsonObject
;
jsonObject
: '{' (jsonPair (',' jsonPair)*)? '}'
;
jsonPair
: STRING ':' expression
;
IF: 'if';
ELSE: 'else';
RETURN: 'return';
AND: '&&';
OR: '||';
NOT: '!';
BOOLEAN: 'true' | 'false';
NUMBER: '-'? [0-9]+ ('.' [0-9]+)?;
STRING: '"' (~["\\] | '\\' .)* '"';
IDENTIFIER: [a-zA-Z_][a-zA-Z0-9_]*;
WS: [ \t\r\n]+ -> skip;
COMMENT: '//' ~[\r\n]* -> skip;
Как видите правил не так много. Нет циклов, переменных и много чего. На начальном этапе мне нужно было просто создать расширение и простую грамматику для языка. По возможности максимально повторить javascript. И возможность добавить скрипт и легко привязать к стабу. И чтобы это правильно выполнилось.
У wiremock есть шаблон или пример на основе которого можно создать свое расширение: wiremock-extension-template
Чтобы добавить грамматику в приложении нужно указать зависимости.
Зависимость в gradle
antlr 'org.antlr:antlr4:4.13.2'
implementation 'org.antlr:antlr4-runtime:4.13.2'
antlr 'org.antlr:antlr4:4.13.2': Инструмент для генерации кода. Плагин antlr в Gradle использует эту зависимость, чтобы запустить ANTLR Tool и сгенерировать Java-классы из .g4 файлов во время сборки проекта. Эти классы появятся в папке build/generated-src/... и будут скомпилированы вместе с остальным кодом.
implementation 'org.antlr:antlr4-runtime:4.13.2': Библиотека для выполнения. Чтобы сгенерированные классы могли правильно работать в рантайме (когда WireMock обрабатывает запрос): создавать токены, строить деревья разбора и обходить их
B проекте нужно еще создать папку src/main/antlr (её нет по умолчанию) и положите туда наш WiremockJs.g4
Дальше в ходе разработки грамматика языка претерпела изменения и стала выглядеть так:
Полная версия грамматикиgrammar WiremockJs;
script
: statement+ EOF
;
statement
: ifStatement
| returnStatement
| varDeclaration
| forEachStatement
;
forEachStatement
: FOR '(' VAR IDENTIFIER OF expression ')' '{' statement* '}'
;
varDeclaration
: VAR IDENTIFIER '=' expression ';'
;
ifStatement
: IF '(' expression ')' '{' thenStmt+=statement+ '}' (ELSE '{' elseStmt+=statement+ '}')?
;
returnStatement
: RETURN expression ';'
;
expression
: expression op=('*'|'/'|'%') expression # MulDiv
| expression op=('+'|'-') expression # AddSub
| expression op=('>'|'>='|'<'|'<=') expression # Compare
| expression op=('=='|'!=') expression # Equality
| expression AND expression # LogicalAnd
| expression OR expression # LogicalOr
| NOT expression # LogicalNot
| functionCall # FuncCallExpr
| fieldAccess # FieldAccessExpr
| IDENTIFIER # VarRefExpr
| literal # LiteralExpr
| '(' expression ')' # ParenExpr
;
functionCall
: IDENTIFIER '(' argumentList? ')'
;
argumentList
: expression (',' expression)*
;
fieldAccess
: IDENTIFIER ('.' IDENTIFIER)+
;
literal
: STRING
| NUMBER
| BOOLEAN
| NULL
| jsonObject
;
jsonObject
: '{' (jsonPair (',' jsonPair)*)? '}'
;
jsonPair
: STRING ':' expression
;
IF: 'if';
ELSE: 'else';
RETURN: 'return';
VAR: 'var';
NULL: 'null';
AND: '&&';
OR: '||';
NOT: '!';
FOR: 'for';
OF: 'of';
BOOLEAN: 'true' | 'false';
NUMBER: '-'? [0-9]+ ('.' [0-9]+)?;
STRING: '"' (~["\\] | '\\' .)* '"';
IDENTIFIER: [a-zA-Z_][a-zA-Z0-9_]*;
WS: [ \t\r\n]+ -> skip;
COMMENT: '//' ~[\r\n]* -> skip;
Компонент | Было (v1) | Стало (v2) | Что изменилось |
|---|---|---|---|
Ключевые слова | 4 ( | 8 (+ | Добавлены переменные, циклы и null |
Типы statement | 2 ( | 4 (+ | Появились объявление переменных и циклы |
Типы выражений | 10 | 11 (+ | Добавлена ссылка на переменную |
Литералы | 4 ( | 5 (+ | Добавлен null-литерал |
Правила грамматики | 16 | 19 | +3 новых правила |
Первая версия грамматики была минималистичной — только условия и возврат. Этого хватало для простых сценариев, но для реальных автотестов требовалось гораздо больше.
Грамматические правилаПравило / Токен | Продукция (синтаксис) | Пояснение |
|---|---|---|
|
| Скрипт состоит из одной или нескольких инструкций, после которых идёт конец файла. |
|
|
|
|
| Объявляет переменную с начальным значением. Область видимости — весь скрипт. |
|
| Цикл по элементам массива (или итерируемого объекта). Переменная цикла получает значение каждого элемента. |
|
| Условный оператор с обязательным блоком |
|
| Возвращает значение вычисленного выражения и завершает выполнение скрипта. |
| `expression op=(‘*’ | ‘/’ |
|
| Вызов функции по имени с нулём или более аргументов. Функции предоставляются окружением (например, |
|
| Список аргументов функции, разделённых запятыми. |
|
| Доступ к свойствам объектов через точечную нотацию. В v2 обязательно хотя бы одно обращение к полю (т.е. |
|
|
|
|
| JSON-объект в фигурных скобках, содержащий ноль или более пар «ключ: значение». |
|
| Пара ключ-значение в JSON-объекте: ключ — строка, значение — любое выражение. |
Токен | Распознаваемое значение | Пояснение |
|---|---|---|
|
| Ключевое слово условного оператора. |
|
| Ключевое слово для альтернативной ветки. |
|
| Ключевое слово оператора возврата. |
|
| Ключевое слово для объявления переменной. |
|
| Ключевое слово цикла. |
|
| Ключевое слово для указания итерируемого объекта в цикле. |
|
| Литерал нулевого значения. |
|
| Логическое И. |
| `’ | |
|
| Логическое отрицание. |
|
|
|
|
| Числовые литералы (целые или дробные, с необязательным знаком минус). |
| `‘"’ (~["\] | ‘\’ .)* ‘"’` |
|
| Имена переменных, функций и полей (буквы, цифры, подчёркивание, не начинаются с цифры). |
|
| Пробельные символы игнорируются. |
|
| Однострочные комментарии (начинаются с |
Перед тем как погрузиться в реализацию, стоит сказать пару слов о том, как ANTLR позволяет обходить дерево разбора. В ANTLR есть два основных паттерна для этого: Listener и Visitor.
Listener работает по принципу «push» — ANTLR автоматически обходит дерево и вызывает ваши методы при входе в узел и выходе из него. Но методы Listener ничего не возвращают, поэтому вам пришлось бы хранить промежуточные результаты в стеке или полях класса
В нашем случае нужно вычислить значение каждого узла и вернуть результат наверх. Это идеальная задача для Visitor. Он работает по принципу «pull» — вы сами управляете обходом, а каждый метод visit может возвращать вычисленное значение (число, строку, булево, JSON-объект). Такой подход естественно ложится на интерпретацию выражений.
ANTLR по грамматике генерирует не только парсер, но и абстрактный класс WiremockJsBaseVisitor<T> с заготовкой метода на каждое правило грамматики — visitIfStatement, visitReturnStatement, visitCompare, visitMulDiv, visitEquality, visitFuncCallExpr, visitLiteral, visitFieldAccessExpr и так далее. Класс WiremockJsInterpreter наследует этот Visitor и переопределяет нужные методы — по сути, это классический паттерн “обойти дерево разбора и на каждом узле посчитать значение”.

image-20260802152246476
Например, для узла сравнения (>, >=, <, <=) Visitor вычисляет обе части выражения рекурсивно (visit(ctx.expression(0)), visit(ctx.expression(1))), приводит их к числу и применяет оператор по тексту токена:
@Override
public Object visitCompare(WiremockJsParser.CompareContext ctx) {
double left = num(visit(ctx.expression(0)));
double right = num(visit(ctx.expression(1)));
String op = ctx.op.getText();
if (op.equals(">")) return left > right;
if (op.equals(">=")) return left >= right;
if (op.equals("<")) return left < right;
if (op.equals("<=")) return left <= right;
throw new ScriptExecutionException("Неизвестный оператор сравнения");
}
Именно так рекурсивный спуск по AST превращается в вычисление: каждый вызов visit(...) — это спуск на дочерний узел дерева, а возврат из метода — “всплытие” вычисленного значения обратно наверх, пока весь скрипт не свернётся в один Map<String, Object>, который visitReturnStatement отдаёт как финальный результат. Доступ к полям объекта через точку (order.amount) реализован аналогично — visitFieldAccessExpr берёт первый идентификатор из объявленных var-переменных в scope, а затем последовательно “спускается” по Map, возвращая null при отсутствующем промежуточном поле и бросая ScriptExecutionException, если базовое значение — не объект
WireMock загружает сторонний код двумя способами: либо класс реализует интерфейс Extension напрямую, либо через ExtensionFactory, который WireMock находит через механизм META-INF/services (Java ServiceLoader).
Есть нюанс, на который стоит обратить внимание: в WireMock 3.13.1 флаг --extensions в CLI ожидает класс, реализующий именно Extension, а не фабрику. Попытка передать туда ExtensionFactory напрямую приведёт к ClassCastException: cannot be cast to class Extension. Поэтому я регистрирую сразу конкретные классы:
--extensions=ru.mcs.wiremockjs.ScriptTransformer,ru.mcs.wiremockjs.admin.ScriptAdminApi.
ScriptTransformer реализует ResponseDefinitionTransformerV2 — это основной движок, который выполняет скрипты.
ScriptAdminApi реализует AdminApiExtension — он добавляет REST-эндпоинты для управления скриптами (загрузка, обновление, удаление) прямо через API WireMock.
Точка входа для логики скриптов — метод transform(ServeEvent) в ScriptTransformer. На каждый входящий запрос, попавший под стаб с transformers: ["wiremock-js"], WireMock вызывает этот метод, передавая ServeEvent, из параметров которого извлекается scriptId, находится соответствующий ScriptDefinition в ScriptStore, и запускается интерпретатор:
public class ScriptTransformer implements ResponseDefinitionTransformerV2 {
private static final long EXECUTION_TIMEOUT_MS = 100;
@Override
public ResponseDefinition transform(ServeEvent serveEvent) {
Parameters parameters = serveEvent.getTransformerParameters();
String scriptId = parameters.getString("scriptId");
if (scriptId == null || scriptId.isBlank()) {
throw new ScriptExecutionException("Параметр scriptId не указан в transformerParameters");
}
ScriptDefinition definition = scriptStore.findById(scriptId).orElseThrow(() -> new ScriptExecutionException("Скрипт не найден: " + scriptId));
ScriptGuard.validate(definition.getSourceCode());
RequestFacade facade = new RequestFacade(serveEvent.getRequest());
WiremockJsInterpreter interpreter = new WiremockJsInterpreter(facade);
Map<String, Object> result = executeWithTimeout(interpreter, definition.getSourceCode());
return buildResponse(serveEvent.getResponseDefinition(), result);
}
}
Для обеспечения безопасности выполнение скрипта я обернул в CompletableFuture с явным вызовом future.get(100, TimeUnit.MILLISECONDS). При истечении таймаута вызывается future.cancel(true), что прерывает выполняющийся поток, после чего выбрасывается ScriptExecutionException. Таймаут в 100 миллисекунд даёт реальную защиту от зависших скриптов на уровне JVM-потока, не позволяя одному некорректному скрипту заблокировать обработку других запросов.
private Map<String, Object> executeWithTimeout(WiremockJsInterpreter interpreter, String source) {
CompletableFuture<Map<String, Object>> future = CompletableFuture.supplyAsync(
() -> interpreter.execute(source));
try {
return future.get(EXECUTION_TIMEOUT_MS, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw new ScriptExecutionException(
"Превышено время выполнения скрипта (" + EXECUTION_TIMEOUT_MS + " мс)");
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof ScriptParseException || cause instanceof ScriptExecutionException) {
throw (RuntimeException) cause;
}
throw new ScriptExecutionException("Ошибка выполнения скрипта: " + cause.getMessage(), cause);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ScriptExecutionException("Выполнение скрипта прервано");
}
}
Идея фасада простая, но важная: скрипт никогда не видит объект Request от WireMock напрямую — только методы RequestFacade, вызываемые через whitelist-функции интерпретатора. Именно поэтому visitFieldAccessExpr жёстко бросает ScriptExecutionException, если скрипт попытается написать request.method — фасад физически не выставляет наружу ничего, кроме своих публичных методов, вызываемых через query(), header(), body() и так далее.
Фасада имеет несколько методов на базе Request из WireMock API:
public class RequestFacade {
private static final Map<String, JsonPath> PATH_CACHE = new ConcurrentHashMap<>();
private final Request request;
private DocumentContext documentContext;
private boolean documentContextInitialized = false;
public RequestFacade(Request request) {
this.request = request;
}
public Object jsonField(String path) {
DocumentContext context = getOrParseDocumentContext();
if (context == null) {
return null;
}
JsonPath compiledPath = PATH_CACHE.computeIfAbsent(path, JsonPath::compile);
try {
return context.read(compiledPath);
} catch (PathNotFoundException e) {
return null;
} catch (Exception e) {
throw new ScriptExecutionException("Ошибка при чтении JSONPath \"" + path + "\": " + e.getMessage());
}
}
private DocumentContext getOrParseDocumentContext() {
if (!documentContextInitialized) {
documentContextInitialized = true;
try {
documentContext = JsonPath.parse(request.getBodyAsString());
} catch (Exception e) {
documentContext = null;
}
}
return documentContext;
}
public String query(String name) {
QueryParameter param = request.queryParameter(name);
return (param != null && param.isPresent()) ? param.firstValue() : null;
}
public String header(String name) {
com.github.tomakehurst.wiremock.http.HttpHeader h = request.header(name);
return (h != null && h.isPresent()) ? h.firstValue() : null;
}
public String body() {
return request.getBodyAsString();
}
public String method() {
return request.getMethod().getName();
}
public String pathSegment(int index) {
List<String> segments = splitPath(request.getUrl());
return index >= 0 && index < segments.size() ? segments.get(index) : null;
}
public Map<String, String> allHeaders() {
Map<String, String> result = new HashMap<>();
for (String key : request.getAllHeaderKeys()) {
result.put(key, request.header(key).firstValue());
}
return result;
}
private List<String> splitPath(String url) {
String path = url.split("\\?")[0];
return java.util.Arrays.stream(path.split("/"))
.filter(s -> !s.isBlank())
.collect(java.util.stream.Collectors.toList());
}
}
Обратите внимание на splitPath — путь сначала очищается от query-строки (url.split("\\?")[0]), затем разбивается на сегменты и фильтруется от пустых строк, что даёт предсказуемую индексацию: /api/customer/vip превращается в ["api", "customer", "vip"], а не в массив с пустым первым элементом из-за ведущего слеша.
То есть изначально было добавлено несколько функций в WiremockJsInterpreter:
private Object callWhitelistedFunction(String name, List<Object> args) {
switch (name) {
case "query": return requestFacade.query(str(args, 0));
case "header": return requestFacade.header(str(args, 0));
case "body": return requestFacade.body();
case "method": return requestFacade.method();
case "pathSegment": return requestFacade.pathSegment((int) num(args, 0));
case "contains":
return str(args, 0) != null && str(args, 0).contains(str(args, 1));
default:
throw new ScriptExecutionException("Функция не разрешена или не существует: " + name);
}
}
Функция | Сигнатура | Возвращает | Описание |
|---|---|---|---|
|
| Значение query-параметра |
|
|
| Значение заголовка |
|
|
| Тело запроса как строка | Без парсинга JSON, сырая строка |
|
| HTTP-метод | Например, |
|
| Сегмент пути по индексу |
|
|
| true/false | Безопасно обрабатывает |
Но потом я расширил и добавил методы: random, now, nowPlusDays, uuid, randomInt, matches, fake, sum, count, avg, mapKeys.
Метод callWhitelistedFunctioncase "query": return requestFacade.query(str(args, 0));
case "header": return requestFacade.header(str(args, 0));
case "body": return requestFacade.body();
case "method": return requestFacade.method();
case "pathSegment": return requestFacade.pathSegment((int) num(args, 0));
case "jsonField": return requestFacade.jsonField(str(args, 0));
case "contains": {
String haystack = str(args, 0);
String needle = str(args, 1);
if (haystack == null || needle == null) {
return false;
}
return haystack.contains(needle);
}
case "random": return random.nextDouble();
case "now": return Instant.now().toString();
case "nowPlusDays": return Instant.now().plus((long) num(args, 0), ChronoUnit.DAYS).toString();
case "uuid": return generateUuid();
case "randomInt": return (double) randomInt((int) num(args, 0), (int) num(args, 1));
case "matches": {
String input = str(args, 0);
String pattern = str(args, 1);
if (input == null || pattern == null) {
return false;
}
try {
return com.google.re2j.Pattern.matches(pattern, input);
} catch (com.google.re2j.PatternSyntaxException e) {
throw new ScriptExecutionException("Некорректное регулярное выражение: " + e.getMessage());
}
}
case "fake": {
String pattern = str(args, 0);
if (pattern == null) {
throw new ScriptExecutionException("fake(): шаблон не может быть null");
}
try {
return faker.expression(pattern);
} catch (Exception e) {
throw new ScriptExecutionException("Ошибка в шаблоне fake(\"" + pattern + "\"): " + e.getMessage());
}
}
case "sum": return sumOf(args.get(0));
case "count": return (double) toList(args.get(0)).size();
case "avg": {
List<Object> list = toList(args.get(0));
if (list.isEmpty()) {
throw new ScriptExecutionException("avg(): пустой массив");
}
return sumOf(args.get(0)) / list.size();
}
case "mapKeys": return mapKeys(toList(args.get(0)), toMap(args.get(1)));
default:
throw new ScriptExecutionException("Функция не разрешена или не существует: " + name);
Идея ScriptGuard в том, чтобы отсеять заведомо плохой скрипт максимально дёшево — без запуска полноценного лексера и парсера ANTLR, которые сами по себе тратят ресурсы на построение дерева разбора. Проверка выполняется единым проходом по массиву символов, попутно считая глубину вложенности скобок и балансировку строк, скобок и фигурных блоков:
public class ScriptGuard {
private static int maxScriptLength = readMaxScriptLength();
private static final int MAX_NESTING_DEPTH = 5;
private static int readMaxScriptLength() {
return Integer.parseInt(System.getProperty("wiremockjs.max.script.length", "2000"));
}
static void reloadMaxScriptLength() {
maxScriptLength = readMaxScriptLength();
}
public static void validate(String source) {
if (source == null || source.isBlank()) {
throw new ScriptTooLargeException("Скрипт не может быть пустым");
}
if (source.length() > maxScriptLength) {
throw new ScriptTooLargeException(
"Скрипт превышает максимальную длину " + maxScriptLength + " символов");
}
int braceDepth = 0;
int maxBraceDepth = 0;
int parenDepth = 0;
boolean inString = false;
char[] chars = source.toCharArray();
for (int i = 0; i < chars.length; i++) {
char c = chars[i];
if (inString) {
if (c == '\\') {
i++; // экранированный символ — пропускаем следующий как есть
} else if (c == '"') {
inString = false;
}
continue;
}
switch (c) {
case '"':
inString = true;
break;
case '{':
braceDepth++;
maxBraceDepth = Math.max(maxBraceDepth, braceDepth);
break;
case '}':
braceDepth--;
if (braceDepth < 0) {
throw new ScriptParseException(
"Лишняя закрывающая скобка '}' без соответствующей открывающей");
}
break;
case '(':
parenDepth++;
break;
case ')':
parenDepth--;
if (parenDepth < 0) {
throw new ScriptParseException(
"Лишняя закрывающая скобка ')' без соответствующей открывающей");
}
break;
default:
break;
}
}
if (inString) {
throw new ScriptParseException("Незакрытая строка — отсутствует завершающая кавычка \"");
}
if (braceDepth != 0) {
throw new ScriptParseException(
"Несбалансированные фигурные скобки '{' '}': не хватает " + braceDepth + " закрывающих");
}
if (parenDepth != 0) {
throw new ScriptParseException(
"Несбалансированные круглые скобки '(' ')': не хватает " + parenDepth + " закрывающих");
}
if (maxBraceDepth > MAX_NESTING_DEPTH) {
throw new ScriptTooLargeException(
"Превышена максимальная глубина вложенности блоков: " + MAX_NESTING_DEPTH);
}
}
}
Вызывать ScriptGuard.validate() нужно было в двух разных точках жизненного цикла скрипта, а не только в одной. Первая точка — ScriptAdminApi на этапе POST/PUT сохранения скрипта: если валидация не проходит, пользователь получает понятный HTTP 400 с телом {"error": "..."}, не дожидаясь первого реального запроса к стабу:
router.add(RequestMethod.POST, "/extensions/wiremock-js/scripts",
(AdminTask) (Admin admin, ServeEvent serveEvent, PathParams pathParams) -> {
Request request = serveEvent.getRequest();
ScriptDefinition def = Json.read(request.getBodyAsString(), ScriptDefinition.class);
try {
ScriptGuard.validate(def.getSourceCode());
} catch (ScriptParseException | ScriptTooLargeException e) {
return errorResponse(e.getMessage(), 400);
}
ScriptDefinition saved = scriptStore.save(def);
return jsonResponse(saved, 201);
});
Вторая точка — ScriptTransformer.transformServeEvent(), где ScriptGuard.validate() вызывается прямо перед запуском интерпретатора на каждый входящий HTTP-запрос. Это выглядит избыточным (скрипт же уже прошёл валидацию при сохранении), но на самом деле закрывает реальный пробел: хранилище скриптов — JSON-файлы на диске, и теоретически файл может быть отредактирован вручную или через прямой доступ к файловой системе, минуя Admin API — вторая проверка гарантирует, что даже в этом случае в интерпретатор не попадёт скрипт, нарушающий лимиты.
Для управления скриптами в wiremock-js-extension реализован отдельный Admin API. Все эндпоинты имеют базовый путь /__admin/extensions/wiremock-js/scripts
Метод | Путь | Описание |
|---|---|---|
|
| Получить список всех скриптов (краткая информация, без |
|
| Поиск скриптов по подстроке в имени (регистронезависимый) |
|
| Получить полную информацию о конкретном скрипте, включая его исходный код |
|
| Создать новый скрипт |
|
| Обновить существующий скрипт |
|
| Удалить скрипт |
Если CRUD методы не взывают вопросов, то вот методы /scripts и /scripts?name=X возможно в будущем мне придется переделать или добавить новые.
В текущей архитектуре один скрипт может быть использован сразу в нескольких стабах. Поэтому для интерфейса потребуется:
Получать список скриптов с привязкой к стабам, чтобы видеть, где используется каждый скрипт, и понимать последствия его изменения или удаления.
Искать стабы, которые либо уже используют скрипты, либо, наоборот, созданы вручную и не привязаны ни к одному скрипту и это поможет выявлять «бесхозные» моки и упрощать миграцию на скриптовый подход.
Таким образом, текущие GET-методы минимально необходимая функциональность, а в перспективе я планирую обогатить API дополнительными фильтрами и агрегирующими запросами, чтобы UI стал по-настоящему удобным инструментом для работы с моками.
Добавление скриптов:Чтобы добавить скрипт нужно выполнить:
curl -X POST http://localhost:8888/__admin/extensions/wiremock-js/scripts \
-H "Content-Type: application/json" \
-d '{
"name": "Approve by amount",
"description": "Одобряет заявку, если сумма меньше 1000",
"sourceCode": "if (query(\"amount\") > 1000) { return { \"approved\": false, \"reason\": \"limit exceeded\" }; } else { return { \"approved\": true }; }"
}'

image-20260802154248174
А дальше уже создать стаб:
curl -X POST http://localhost:8888/__admin/mappings \
-H "Content-Type: application/json" \
-d '{
"request": { "method": "GET", "urlPath": "/api/orders/approve" },
"response": {
"status": 200,
"transformers": ["wiremock-js"],
"transformerParameters": { "scriptId": "<ID>" }
}
}'

image-20260802154558953
И проверить так:
curl "http://localhost:8888/api/orders/approve?amount=2000"
# {"approved":false,"reason":"limit exceeded"}
curl "http://localhost:8888/api/orders/approve?amount=500"
# {"approved":true}

image-20260802154907586

image-20260802154919324
Первая версия языка умела читать только query-параметры, заголовки и сегменты пути — этого хватало для простых сценариев вроде “одобрить по сумме” или “проверить токен”, но реальные тестовые сценарии в практике чаще приходят как JSON в теле POST/PUT-запроса, а не как query-строка. Без доступа к полям JSON-тела скрипт мог различать заказы только по внешним параметрам запроса, а не по содержимому самого заказа — то есть нельзя было написать условие вида “если order.amount больше лимита и клиент не VIP” Но потом я добавил функции.
jsonField(path) это безопасный и удобный способ для скрипта получить доступ к данным из тела входящего HTTP-запроса в формате JSON. Он закрыл проблему с json: единая функция, которая парсит тело один раз (лениво, с кэшированием в parsedBody()), а затем навигируется по дереву JsonNode через путь с точками, поддерживая одновременно и текстовые ключи, и числовые индексы массива в одном выражении — jsonField("items.0.id").
Если тело — не JSON или отсутствует вовсе, jsonField не бросает исключение, а возвращает null, и это сделано специально — скрипт не должен падать только потому, что запрос пришёл без тела. Это же решение проверяется тестом shouldReturnNullForInvalidJsonBody, где на вход подаётся строка "not a json at all" и ожидается спокойный null, а не exception.
@Test
@DisplayName("jsonField() безопасно обрабатывает невалидный JSON в теле")
void shouldReturnNullForInvalidJsonBody() {
when(mockRequest.getBodyAsString()).thenReturn("not a json at all");
String script = """
return { "field": jsonField("anything") };
""";
Map<String, Object> result = run(script);
assertEquals(null, result.get("field"));
}
Примеры:
Для JSON-запроса {"user": {"role": "admin"}} вызов jsonField("user.role") вернёт строку "admin".
Для запроса {"items": [{"id": 42}]} вызов jsonField("items[0].id") вернёт число 42.
jsonField() использует движок Jayway JsonPath, поэтому поддерживает не только простую точечную навигацию, но и полноценные операторы выбора элементов массива, вайлдкарды и фильтрацию по условию.
Синтаксис | Пример | Возвращает | Когда использовать |
|---|---|---|---|
Простой ключ |
|
| Доступ к вложенному полю объекта |
Индекс массива |
|
| Один конкретный элемент массива по позиции |
Вайлдкард (все элементы) |
|
| Собрать значения одного поля со всех элементов массива, например перед |
Фильтр по условию |
|
| Выбрать только те элементы массива, что удовлетворяют условию |
Фильтр без совпадений |
|
| Функция не бросает исключение — возвращает пустой список, а не null |
Несуществующий путь |
|
| Скрипт не падает, если промежуточное поле отсутствует |
Невалидное тело запроса |
|
| Защита от падения на запросах без JSON-тела |
Вайлдкард (items[*].field) и фильтр (items[?(@.field=='value')].field) всегда возвращают список, даже если в массиве совпал ровно один элемент — поэтому sum(), count() и avg() рассчитаны именно на такой список на входе, а не на скалярное значение. Простой путь без [*] или [?(...)], наоборот, всегда возвращает одно значение или null — смешивать эти два режима в одном выражении нельзя
Как работают now() и nowPlusDays():
case "now": return Instant.now().toString();
case "nowPlusDays": return Instant.now().plus((long) num(args, 0), ChronoUnit.DAYS).toString();
now() возвращает текущий момент времени в формате ISO-8601 (UTC), например 2026-08-03T00:27:15.123Z, а nowPlusDays(n) берёт текущее время и добавляет к нему n суток, тоже возвращая строку в том же формате. Ключевое отличие от random()/uuid() — эти функции полностью независимы от seed, потому что берут значение из системных часов через Instant.now(), а не из генератора псевдослучайных чисел — вы уже убедились в этом на примере с фейковым адресом доставки, где createdAt менялся при каждом вызове даже при фиксированном seed: 7.
Пример — дата создания заказа и срок доставки
Практичный сценарий: сервис принимает заказ и сразу возвращает ожидаемую дату доставки через определённое количество дней.
Скрипт:
var orderId = uuid();
var createdAt = now();
var estimatedDelivery = nowPlusDays(3);
return {
"status": 200,
"body": {
"orderId": orderId,
"createdAt": createdAt,
"estimatedDelivery": estimatedDelivery
}
};
Добавление скрипта в wiremock:
curl --request POST \
--url http://localhost:8888/__admin/extensions/wiremock-js/scripts \
--header 'Content-Type: application/json' \
--data '{
"name": "Order with delivery estimate",
"description": "Дата создания заказа и расчётная дата доставки через 3 дня",
"sourceCode": "var orderId = uuid(); var createdAt = now(); var estimatedDelivery = nowPlusDays(3); return { \"status\": 200, \"body\": { \"orderId\": orderId, \"createdAt\": createdAt, \"estimatedDelivery\": estimatedDelivery } };"
}'

image-20260803003901425
curl --request POST \
--url http://localhost:8888/__admin/mappings \
--header 'Content-Type: application/json' \
--data '{"request": {"method": "POST", "urlPath": "/api/orders/create"}, "response": {"status": 200, "transformers": ["wiremock-js"], "transformerParameters": {"scriptId": "804b0555-d1a0-4db7-b6cc-60a9d1af4eb7"}}}'

image-20260803003942053
curl --request POST \
--url http://localhost:8888/api/orders/create \
--header 'Content-Type: application/json' \
--data '{
"amount": 1500
}'

image-20260803004016820
Функция | Сигнатура | Возвращает | Описание |
|---|---|---|---|
|
| ISO-8601 timestamp с миллисекундами | Например, |
|
| ISO-8601 timestamp |
|
Здесь решение было чуть менее тривиальным, потому что обе функции должны были подчиняться общему seed, а UUID.randomUUID() из стандартной библиотеки использует свой внутренний SecureRandom и не принимает внешний Random. Поэтому uuid() собирается вручную из байтов общего генератора random, с явной установкой битов версии и варианта UUID v4, а randomInt(min, max) — через random.nextInt(max - min + 1) + min с проверкой min > max:
javacase "uuid": return generateUuid();
case "randomInt": return (double) randomInt((int) num(args, 0), (int) num(args, 1));
Из-за этого uuid() при одинаковом seed детерминирован ровно так же, как random() — весь набор случайных функций подключён к одному генератору, и это делает воспроизводимыми не только числовые chaos-сценарии, но и сценарии с уникальными идентификаторами в CI/CD.
Тут стоит подробнее расcказать про seed. Что такое seed простыми словами?
Компьютер физически не умеет генерировать по-настоящему случайные числа — то, что мы называем “случайностью” в коде, на самом деле результат детерминированного математического алгоритма, который на каждом шаге вычисляет следующее число из предыдущего. Seed (“зерно” или “начальное число”) — это тот самый стартовый параметр, с которого алгоритм начинает вычислять свою последовательность: если задать одно и то же зерно, генератор каждый раз выдаст абсолютно одинаковую цепочку “случайных” чисел, а если зерно не задавать явно, программа обычно берёт текущее системное время, и тогда каждый запуск даёт разную, непредсказуемую последовательность.
Наглядная аналогия — книга с заранее напечатанными последовательностями чисел: seed — это просто номер страницы, с которой алгоритм начинает читать. Открыть книгу на 42-й странице два раза подряд — и вы увидите одни и те же числа в том же порядке; открыть без указания страницы — и каждый раз получите случайное место. На хабре есть статьи на эту тему: habr
Зачем seed нужен именно в WiremockJsВ обычном мокировании непредсказуемость random() — это ценность: она имитирует реальный “шум” внешнего сервиса. Но в CI/CD-пайплайне непредсказуемость превращается в проблему — если тест на retry-логику иногда получает 500, а иногда 200 случайным образом, прогон пайплайна становится нестабильным (flaky), и непонятно, баг ли это в вашем коде или просто “не повезло” с рандомом в этот раз.
Поэтому в ScriptDefinition предусмотрено опциональное поле seed — если оно указано, генератор внутри интерпретатора инициализируется как new Random(seed), и повторный вызов того же скрипта с тем же seed даёт идентичную последовательность значений у random(), randomInt(), uuid() и fake(). Без seed (поле null или отсутствует) генератор ведёт себя как обычный Math.random() — непредсказуемо на каждый запуск. Это подтверждено тестом shouldBeDeterministicWithSameSeed, где два независимых интерпретатора с одинаковым seed = 42 дают идентичные результаты random() трижды подряд, а тест shouldDifferWithDifferentSeeds с seed = 1 и seed = 2 показывает, что значения расходятся.
@Test
@DisplayName("random() с одинаковым seed даёт одинаковую последовательность")
void shouldBeDeterministicWithSameSeed() {
String script = """
return { "a": random(), "b": random(), "c": random() };
""";
WiremockJsInterpreter interpreter1 = new WiremockJsInterpreter(new RequestFacade(mockRequest));
WiremockJsInterpreter interpreter2 = new WiremockJsInterpreter(new RequestFacade(mockRequest));
Map<String, Object> result1 = interpreter1.execute(script, 42);
Map<String, Object> result2 = interpreter2.execute(script, 42);
assertEquals(result1.get("a"), result2.get("a"));
assertEquals(result1.get("b"), result2.get("b"));
assertEquals(result1.get("c"), result2.get("c"));
}
Пример:
{
"name": "Chaos 5 percent errors",
"sourceCode": "if (random() < 0.05) { return { \"status\": 500 }; } else { return { \"status\": 200 }; }",
"seed": 42
}
С этим seed: 42 каждый прогон CI против одного и того же стаба даёт одну и ту же последовательность “случайных” ошибок — воспроизводимость сохраняется даже там, где по смыслу должна быть непредсказуемость.
Соберём конкретный пример на chaos-сценарии с seed, создадим скрипт, привяжем его к стабу и вызовем несколько раз, чтобы увидеть детерминизм на практике.
curl -X POST http://localhost:8888/__admin/extensions/wiremock-js/scripts \
-H "Content-Type: application/json" \
-d '{
"name": "Chaos with seed demo",
"description": "Демонстрация детерминизма random() и uuid() при заданном seed",
"sourceCode": "if (random() < 0.3) { return { \"status\": 500, \"body\": { \"error\": \"internal error\", \"requestId\": uuid() } }; } else { return { \"status\": 200, \"body\": { \"ok\": true, \"requestId\": uuid() } }; }",
"seed": 42
}'
В ответе придёт scriptId, например a1b2c3d4-... — его нужно подставить в стаб на следующем шаге. У меня это 9e0a8a67-424d-47f4-8fbc-f1afa8736b49.

image-20260802164009648
Шаг 2 — создаём стаб, привязанный к скриптуcurl -X POST http://localhost:8888/__admin/mappings \
-H "Content-Type: application/json" \
-d '{
"request": { "method": "GET", "urlPath": "/api/chaos/demo" },
"response": {
"status": 200,
"transformers": ["wiremock-js"],
"transformerParameters": { "scriptId": "<ID из шага 1>" }
}
}'

image-20260802164118934
Шаг 3 — вызываем стаб несколько разcurl http://localhost:8888/api/chaos/demo
curl http://localhost:8888/api/chaos/demo
curl http://localhost:8888/api/chaos/demo

image-20260802165650835

image-20260802165701711

image-20260802171500886
Как это ведёт себя на практикеПоскольку seed = 42 задан явно, генератор внутри WiremockJsInterpreter инициализируется как new Random(42) на каждый вызов скрипта, а не переиспользуется между вызовами — то есть каждый отдельный HTTP-запрос запускает интерпретатор с нуля, и random() на первом же вычислении внутри одного и того же скрипта с одним и тем же seed всегда возвращает одно и то же первое значение из последовательности. Это значит, что при таком коде все три curl-вызова выше дадут абсолютно одинаковый результат — либо всегда {"status": 500, ...}, либо всегда {"status": 200, ...}, в зависимости от того, какое первое число даёт new Random(42).nextDouble().
Если seed не указан то каждый вызов выводит или 200 статус ответа или 500 в соответствии с условием скрипта. Суть seed — он не просто делает “предсказуемым один вызов”, а превращает весь скрипт в чистую функцию от seed: одинаковый вход всегда даёт одинаковый выход, что удобно для юнит-тестов, но неожиданно для тех, кто без объяснения ждёт от uuid() уникальности на каждый вызов.
Важно помнить, что matches() в WiremockJs проверяет полное совпадение всей строки с шаблоном (через RE2J, а не стандартный java.util.regex), и это точь-в-точь как Pattern.matches() — без частичного поиска подстроки.
Практичный сценарий: сервис доставки принимает заказ и должен убедиться, что номер телефона клиента соответствует ожидаемому формату, прежде чем создавать заявку.
Скрипт:
var phone = jsonField("phone");
if (!matches(phone, "\\+7\\d{10}")) {
return {
"status": 400,
"body": {
"error": "invalid phone format",
"expected": "+7XXXXXXXXXX"
}
};
}
return {
"status": 200,
"body": {
"orderId": uuid(),
"phone": phone,
"status": "CREATED"
}
};
curl --request POST \
--url http://localhost:8888/__admin/extensions/wiremock-js/scripts \
--header 'Content-Type: application/json' \
--data '{
"name": "Validate phone format",
"sourceCode": "var phone = jsonField(\"phone\"); if (!matches(phone, \"\\+7\\d{10}\")) { return { \"status\": 400, \"body\": { \"error\": \"invalid phone format\", \"expected\": \"+7XXXXXXXXXX\" } }; } return { \"status\": 200, \"body\": { \"orderId\": uuid(), \"phone\": phone, \"status\": \"CREATED\" } };"
}'

image-20260803010019847
curl --request POST \
--url http://localhost:8888/__admin/mappings \
--header 'Content-Type: application/json' \
--data '{
"request": {
"method": "POST",
"urlPath": "/api/orders/validate-phone"
},
"response": {
"status": 200,
"transformers": [
"wiremock-js"
],
"transformerParameters": {
"scriptId": "ae807684-a5db-4fe0-b3d0-33b1ee88264e"
}
}
}'

image-20260803010043854
curl --request POST \
--url http://localhost:8888/api/orders/validate-phone \
--header 'Content-Type: application/json' \
--data '{
"phone": "+79161234567"
}'

image-20260803010119331
Пример 2 - более сложный пример в котором реализована маршрутизация по нескольким форматам телефонаСкрипт:
Маршрутизация по нескольким форматам телефонаvar phone = jsonField("phone");
if (matches(phone, "\\+79\\d{9}")) {
return {
"status": 200,
"body": {
"phone": phone,
"country": "RU",
"type": "mobile",
"valid": true
}
};
}
if (matches(phone, "\\+7[34568]\\d{9}")) {
return {
"status": 200,
"body": {
"phone": phone,
"country": "RU",
"type": "landline",
"valid": true
}
};
}
if (matches(phone, "8\\d{10}")) {
return {
"status": 200,
"body": {
"phone": phone,
"country": "RU",
"type": "mobile_legacy_format",
"valid": true,
"warning": "use +7 format instead"
}
};
}
if (matches(phone, "\\+1\\d{10}")) {
return {
"status": 200,
"body": {
"phone": phone,
"country": "US",
"type": "mobile",
"valid": true
}
};
}
return {
"status": 400,
"body": {
"phone": phone,
"valid": false,
"error": "unrecognized phone format"
}
}
curl --request POST \
--url http://localhost:8888/__admin/extensions/wiremock-js/scripts \
--header 'Content-Type: application/json' \
--data '{
"name": "Phone classifier",
"description": "Классифицирует номер телефона по формату и оператору",
"sourceCode": "var phone = jsonField(\"phone\"); if (matches(phone, \"\\+79\\d{9}\")) { return { \"status\": 200, \"body\": { \"phone\": phone, \"country\": \"RU\", \"type\": \"mobile\", \"valid\": true } }; } if (matches(phone, \"\\+7[34568]\\d{9}\")) { return { \"status\": 200, \"body\": { \"phone\": phone, \"country\": \"RU\", \"type\": \"landline\", \"valid\": true } }; } if (matches(phone, \"8\\d{10}\")) { return { \"status\": 200, \"body\": { \"phone\": phone, \"country\": \"RU\", \"type\": \"mobile_legacy_format\", \"valid\": true, \"warning\": \"use +7 format instead\" } }; } if (matches(phone, \"\\+1\\d{10}\")) { return { \"status\": 200, \"body\": { \"phone\": phone, \"country\": \"US\", \"type\": \"mobile\", \"valid\": true } }; } return { \"status\": 400, \"body\": { \"phone\": phone, \"valid\": false, \"error\": \"unrecognized phone format\" } };"
}'

image-20260803011148247
curl --request POST \
--url http://localhost:8888/__admin/mappings \
--header 'Content-Type: application/json' \
--data '{
"request": {
"method": "POST",
"urlPath": "/api/phones/classify"
},
"response": {
"status": 200,
"transformers": [
"wiremock-js"
],
"transformerParameters": {
"scriptId": "f3358922-faed-4159-9538-263e43a99d77"
}
}
}'

image-20260803011222740
Российский мобильный (+7 9XX):
curl --request POST \
--url http://localhost:8888/api/phones/classify \
--header 'Content-Type: application/json' \
--data '{"phone": "+79161234567"}'
{ "phone": "+79161234567", "country": "RU", "type": "mobile", "valid": true }
Российский стационарный (+7, но не 9):
curl --request POST --url http://localhost:8888/api/phones/classify --header 'Content-Type: application/json' --data '{"phone": "+74951234567"}'
{ "phone": "+74951234567", "country": "RU", "type": "landline", "valid": true }
Устаревший формат с 8 вместо +7:
curl --request POST --url http://localhost:8888/api/phones/classify --header 'Content-Type: application/json' --data '{"phone": "89161234567"}'
{ "phone": "89161234567", "country": "RU", "type": "mobile_legacy_format", "valid": true, "warning": "use +7 format instead" }
Американский номер:
curl --request POST --url http://localhost:8888/api/phones/classify --header 'Content-Type: application/json' --data '{"phone": "+12025551234"}'
{ "phone": "+12025551234", "country": "US", "type": "mobile", "valid": true }
Неопознанный формат
curl --request POST --url http://localhost:8888/api/phones/classify --header 'Content-Type: application/json' --data '{"phone": "12345"}'
{ "phone": "12345", "valid": false, "error": "unrecognized phone format" }
Примечание:
Грамматика WiremockJs объявляет STRING-токен с поддержкой escape-последовательностей ('"' (~["\\] | '\\' .)* '"'), что создаёт обманчивое впечатление, будто \n, \t или \" внутри строки скрипта превратятся в реальный перевод строки, табуляцию или кавычку. На практике это не так: метод unquote() в WiremockJsInterpreter просто вырезает подстроку между первой и последней кавычкой символ в символ, никак не интерпретируя backslash — то есть литерал "\\n" в скрипте останется двумя символами \ и n, а не превратится в реальный перевод строки.
Это особенно критично для регулярных выражений в matches(). Когда вы отправляете скрипт через curl в JSON-теле вида "sourceCode": "matches(phone, \"\\\\+7\\\\d{10}\")", происходит два независимых слоя экранирования:
JSON-парсер на входе (при сохранении скрипта через Admin API) превращает \\\\+7\\\\d{10} в строку \+7\d{10} — по одному backslash перед каждым спецсимволом.
WiremockJs-парсер при разборе строкового литерала "\+7\d{10}" внутри скрипта не трогает backslash вообще, потому что unquote() не занимается escape-обработкой — итоговое значение, которое попадёт в matches(), будет ровно \+7\d{10}, что и нужно RE2J как валидный паттерн.
Отдельная функция matches(str, pattern) для проверки строки на regex создала новый риск — классический ReDoS через catastrophic backtracking в java.util.regex. Проблема конкретная: злой или просто неудачный паттерн типа (a+)+ в связке со строкой из полусотни символов a и без финального совпадения может заставить стандартный движок регулярок работать экспоненциально долго, а поскольку matches() вызывается из пользовательского скрипта, это открытая дверь для DoS через сам механизм мокирования.
@Test
@DisplayName("matches() безопасно обрабатывает потенциально катастрофический паттерн без зависания")
void shouldHandleCatastrophicPatternSafely() {
String script = """
if (matches("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!", "(a+)+b")) {
return { "valid": true };
} else {
return { "valid": false };
}
""";
long start = System.currentTimeMillis();
Map<String, Object> result = run(script);
long elapsed = System.currentTimeMillis() - start;
assertEquals(false, result.get("valid"));
assertTrue(elapsed < 500);
}
Я это проверяю тестом с ReDoS-паттерном — строка из ~150 символов a плюс ! в конце против паттерна (a+)+$, и с java.util.regex такой ввод завис бы на неопределённое время, а таймаут в 100 мс на уровне CompletableFuture в ScriptTransformer тут не спасает по-настоящему: future.cancel(true) пытается прервать поток через Thread.interrupt(), но Matcher из java.util.regex не проверяет флаг прерывания во время backtracking-цикла, и поток продолжает жечь CPU даже после “отмены”.
Решил заменить движок регулярок целиком на com.google.re2j:re2j:1.7, который реализует Google RE2: вместо backtracking там DFA-подобный алгоритм с гарантированным линейным временем выполнения O(n) от длины строки, независимо от паттерна. Плата за эту гарантию — RE2J не поддерживает backreferences (\1, \2) и часть сложных lookahead/lookbehind конструкций, но для типовых задач мокирования (email, телефон, формат ID) это не нужно:
dependencies {
implementation 'com.google.re2j:re2j:1.7'
}
case "matches": {
String input = str(args, 0);
String pattern = str(args, 1);
if (input == null || pattern == null) {
return false;
}
try {
return com.google.re2j.Pattern.matches(pattern, input);
} catch (com.google.re2j.PatternSyntaxException e) {
throw new ScriptExecutionException("Некорректное регулярное выражение: " + e.getMessage());
}
}
fake() — заглушка для реалистичных данныхОтдельная категория пробела — реалистичные тестовые данные (имена, email, адреса), которые вручную писать в каждом скрипте было бы утомительно. Решение — подключить DataFaker и пробросить единственную функцию fake(pattern), которая делегирует всю работу выражению DataFaker:
case "fake": {
String pattern = str(args, 0);
if (pattern == null) throw new ScriptExecutionException("fake(): шаблон не может быть null");
try {
return faker.expression(pattern);
} catch (Exception e) {
throw new ScriptExecutionException("Ошибка в шаблоне fake(\"" + pattern + "\"): " + e.getMessage());
}
}
Здесь особенно показательна экономия усилий — вместо того чтобы придумывать whitelist из десятков отдельных функций (fakeName(), fakeEmail(), fakeAddress()…), я прокинул в язык ровно одну функцию, которая принимает строковый шаблон DataFaker ("Name.firstName", "Internet.emailAddress") и делегирует всю сложность генерации самой библиотеке. Как и uuid(), fake() инициализируется с тем же seed, что даёт Faker в режиме repeatable-данных — снова тот же принцип “один seed управляет всей случайностью скрипта”.
Самый базовый сценарий: сервис возвращает случайного, но реалистичного клиента при каждом запросе.
curl --request POST \
--url http://localhost:8888/__admin/extensions/wiremock-js/scripts \
--header 'Content-Type: application/json' \
--data '{
"name": "Fake customer",
"description": "Возвращает случайного клиента через DataFaker",
"sourceCode": "var firstName = fake(\"#{Name.first_name}\"); var lastName = fake(\"#{Name.last_name}\"); var email = fake(\"#{Internet.email_address}\"); return { \"status\": 200, \"body\": { \"id\": uuid(), \"firstName\": firstName, \"lastName\": lastName, \"email\": email } };"
}'
curl --request POST \
--url http://localhost:8888/__admin/mappings \
--header 'Content-Type: application/json' \
--data '{
"request": {
"method": "GET",
"urlPath": "/api/customers/random"
},
"response": {
"status": 200,
"transformers": [
"wiremock-js"
],
"transformerParameters": {
"scriptId": "<ID>"
}
}
}'
curl http://localhost:8888/api/customers/random
Каждый вызов вернёт новое имя, фамилию и email — без seed это по-настоящему случайные данные на каждый запрос.

image-20260802173523491

image-20260802173538694
Пример 2 — детерминированный фейковый заказ через seedЗдесь показывается связка fake() + seed — полезно для тестов, где нужны стабильные, но реалистичные данные:
curl --request POST \
--url http://localhost:8888/__admin/extensions/wiremock-js/scripts \
--header 'Content-Type: application/json' \
--data '{
"name": "Fake order with fixed seed",
"description": "Одинаковый фейковый адрес доставки при каждом прогоне теста",
"sourceCode": "var city = fake(\"#{Address.city}\"); var street = fake(\"#{Address.street_address}\"); return { \"status\": 200, \"body\": { \"orderId\": uuid(), \"deliveryAddress\": { \"city\": city, \"street\": street }, \"createdAt\": now() } };",
"seed": 7
}'
curl --request POST \
--url http://localhost:8888/__admin/mappings \
--header 'Content-Type: application/json' \
--data '{
"request": {
"method": "GET",
"urlPath": "/api/orders/fake"
},
"response": {
"status": 200,
"transformers": [
"wiremock-js"
],
"transformerParameters": {
"scriptId": "<ID>"
}
}
}'
curl --request GET --url http://localhost:8888/api/orders/fake

image-20260802174147354

image-20260802174201356
Поскольку Faker внутри Visitor создаётся с тем же Random(seed), что и uuid(), при seed: 7 каждый повторный вызов этого скрипта даст один и тот же city, street и orderId — а вот createdAt будет всегда новым, потому что now() берёт реальное системное время, а не генератор случайных чисел.
Более практичный кейс — тело ответа собирается частично из входного JSON, частично из faker, что типично для webhook-имитаций:
curl --request POST \
--url http://localhost:8888/__admin/extensions/wiremock-js/scripts \
--header 'Content-Type: application/json' \
--data '{
"name": "Payment webhook with fake merchant",
"description": "paymentId берётся из запроса, merchant данные — фейковые",
"sourceCode": "var paymentId = jsonField(\"paymentId\"); if (paymentId == null) { return { \"status\": 400, \"body\": { \"error\": \"paymentId is required\" } }; } var merchantName = fake(\"#{Company.name}\"); return { \"status\": 200, \"body\": { \"paymentId\": paymentId, \"status\": \"COMPLETED\", \"merchant\": merchantName, \"processedAt\": now() } };"
}'
curl --request POST \
--url http://localhost:8888/__admin/mappings \
--header 'Content-Type: application/json' \
--data '{
"request": {
"method": "POST",
"urlPath": "/api/payments/webhook"
},
"response": {
"status": 200,
"transformers": [
"wiremock-js"
],
"transformerParameters": {
"scriptId": "<ID>"
}
}
}'
curl --request POST \
--url http://localhost:8888/api/payments/webhook \
--header 'Content-Type: application/json' \
--data '{"paymentId": "pay_12345"}'

image-20260802174732358

image-20260802174835964
{
"paymentId": "pay_12345",
"status": "COMPLETED",
"merchant": "<случайное название компании>",
"processedAt": "<текущее время>"
}
Вызов:
curl-X POST http://localhost:8888/api/payments/webhook -H "Content-Type: application/json" -d '{}'
Ожидаемый ответ — HTTP 400:

image-20260802174910120
Здесь jsonField("paymentId") читает реальные данные запроса, fake("Company.name") добавляет реалистичный, но не фиксированный элемент, а if (paymentId == null) показывает уже знакомую нам защиту от отсутствующих полей.
Категория | Шаблон | Пример результата (EN) | Пример результата (RU) |
|---|---|---|---|
Полное имя |
| Miss Samanta Schmidt | Иванова Мария Петровна |
Имя |
| Norman | Мария |
Фамилия |
| O’Reilly | Иванова |
| |||
Домен |
| example.com | example.ru |
Компания |
| Mohamedland Inc. | ПАО ИвановоСбытСнабСбыт |
Слоган компании |
| Innovative solutions for tomorrow | Инновационные решения для будущего |
Город |
| Mohamedland | Москва |
Полный адрес |
| 4890 Homenick Junctions, Springfield | г. Москва, ул. Ленина, д. 15 |
Улица |
| 4890 Homenick Junctions | ул. Ленина, д. 15 |
Почтовый индекс |
| 90210 | 101000 |
Страна |
| Suriname | Россия |
Телефон |
| +1-555-0134 | +7-900-123-45-67 |
Предложение (текст) |
| Случайное предложение на английском | Случайное предложение (обычно на английском, даже в ru-локали) |
Слово |
| apple | яблоко |
Номер кредитной карты |
| 4111-1111-1111-1111 | 4111-1111-1111-1111 |
Название валюты |
| USD | RUB |
Профессия |
| Software Engineer | Инженер-программист |
UUID (через Faker) |
| Альтернатива встроенному | Альтернатива встроенному |
Примечание:
В плагине я зашил хардкорно RU локализацию для фейковых данных.
public class FakerHolder {
private static volatile Faker instance;
public static synchronized Faker getInstance() {
if (instance == null) {
instance = new Faker(new Locale("ru"));
}
return instance;
}
private FakerHolder() {}
}
Если нужно EN нужно будет пересобрать с этим параметром. Не выносил так как планировал перевести и сообщения об ошибках и доработать UI. А там могут быть ньюансы о которых я еще не подозреваю.
Комбинирование нескольких значений в одном вызовеПоскольку expression() ищет все подстроки #{...} внутри произвольного текста, можно собрать целое сообщение одним вызовом fake(), без промежуточных переменных, как вы уже видели на примере с merchant:
"sourceCode": "var summary = fake(\"Клиент #{Name.full_name} из компании #{Company.name}, email: #{Internet.email_address}\"); return { \"status\": 200, \"body\": { \"summary\": summary } };"
Обратите внимание — синтаксис шаблона зависит от версии DataFaker (1.9.0 использует нотацию Category.method_name через snake_case, тогда как некоторые примеры в интернете написаны под 2.x с другим стилем) — если fake(...) бросает ScriptExecutionException с сообщением про несуществующий провайдер, стоит свериться с точным списком методов конкретно для версии 1.9.0, которую вы сейчас используете после downgrade
Последний штрих в этой волне — задача переименования ключей в массиве объектов (например, API отдаёт {"cost": 100}, а тест ожидает {"price": 100}). Полноценное решение через произвольные выражения (map(array, item => ...)) означало бы добавление eval-семантики в язык, а это противоречило всей идее ограниченного DSL — поэтому вместо этого добавил узкую, но достаточную функцию mapKeys(array, mapping), которая просто переименовывает ключи по статическому словарю соответствий, без вычисления произвольных выражений на каждый элемент.
Основная причина — безопасность выполнения в изолированном интерпретаторе. Циклы (for, while) в языке, встроенном в HTTP-мок-сервер, открывают прямой путь к runaway-выполнению: пользователь может написать while (true) {} или цикл с ошибкой в условии выхода, и единственная защита от зависшего потока — таймаут в ScriptTransformer (EXECUTION_TIMEOUT_MS), который просто прерывает Future, но не гарантирует, что поток реально остановится мгновенно и не продолжит жрать CPU в фоне. Я уже сталкивалися с тем, как чувствителен этот таймаут — даже 100 мс холодного старта Faker ловил ошибку, а произвольный цикл без верхней границы итераций — куда более серьёзный риск для продакшен-инстанса WireMock, который обслуживает множество стабов одновременно
sum(), count() и avg() решают ту же практическую задачу — обработку массива данных — но при этом:
Ограничены по построению — они всегда проходят ровно по одному конкретному списку один раз, без возможности случайной бесконечной итерации.
Не требуют произвольного пользовательского кода внутри тела цикла — вся логика агрегации зашита в sumOf(), а не в скрипте, значит нет риска, что пользователь напишет внутри цикла что-то тяжёлое или рекурсивное.
Предсказуемы по времени выполнения — сложность O(n) от размера массива, который и так ограничен реальным размером тела HTTP-запроса, а не потенциально бесконечен, как условие произвольного while.
Все три функции принимают один аргумент — массив (List<Object>), обычно полученный через jsonField() с wildcard-путём типа orders.price, который возвращает не одно значение, а список чисел из всех элементов массива:
case "sum": return sumOf(args.get(0));
case "count": return (double) toList(args.get(0)).size();
case "avg": {
List<Object> list = toList(args.get(0));
if (list.isEmpty()) {
throw new ScriptExecutionException("avg(): пустой массив");
}
return sumOf(args.get(0)) / list.size();
}
sum() проходит по каждому элементу списка через sumOf() и складывает их как числа (с приведением типов через num()), count() просто возвращает размер списка независимо от содержимого, а avg() делит сумму на количество — с явной защитой от деления на ноль, если массив окажется пустым. Обратите внимание на важную деталь: avg() бросает ScriptExecutionException при пустом массиве, а не возвращает NaN или 0 — это осознанное решение сделать ошибку явной, а не молча замаскировать проблему в данных запроса.
Практичный сценарий: клиент присылает список товаров с ценами, а стаб должен вернуть общую стоимость заказа и количество позиций.
curl --request POST \
--url http://localhost:8888/__admin/extensions/wiremock-js/scripts \
--header 'Content-Type: application/json' \
--data '{
"name": "Order total calculator",
"sourceCode": "var prices = jsonField(\"items[*].price\"); var total = sum(prices); var itemsCount = count(prices); return { \"status\": 200, \"body\": { \"totalPrice\": total, \"itemsCount\": itemsCount } };"
}'

image-20260802230105456
curl --request POST \
--url http://localhost:8888/__admin/mappings \
--header 'Content-Type: application/json' \
--data '{
"request": {
"method": "POST",
"urlPath": "/api/orders/calculate"
},
"response": {
"status": 200,
"transformers": [
"wiremock-js"
],
"transformerParameters": {
"scriptId": "<ScriptID>"
}
}
}'

image-20260802230238209
curl --request POST \
--url http://localhost:8888/api/orders/calculate \
--header 'Content-Type: application/json' \
--data '{
"items": [
{
"price": 100
},
{
"price": 250
},
{
"price": 50
}
]
}'

image-20260802230252953
Результат:
{
"totalPrice": 400,
"itemsCount": 3
}
Здесь jsonField("items[*].price") собирает все значения price из массива items в единый список [100, 250, 50], а sum() и count() работают уже с этим готовым списком, а не с исходным JSON напрямую.
Этот пример показывает, как обработать защиту от пустого массива через if, не дожидаясь исключения:
curl --request POST \
--url http://localhost:8888/__admin/extensions/wiremock-js/scripts \
--header 'Content-Type: application/json' \
--data '{
"name": "Average order value",
"description": "Считает средний чек по позициям заказа",
"sourceCode": "var prices = jsonField(\"items[*].price\"); if (count(prices) == 0) { return { \"status\": 400, \"body\": { \"error\": \"order has no items\" } }; } var average = avg(prices); return { \"status\": 200, \"body\": { \"averagePrice\": average, \"itemsCount\": count(prices) } };"
}'

image-20260802235700141
curl --request POST \
--url http://localhost:8888/__admin/mappings \
--header 'Content-Type: application/json' \
--data '{
"request": {
"method": "POST",
"urlPath": "/api/orders/average"
},
"response": {
"status": 200,
"transformers": [
"wiremock-js"
],
"transformerParameters": {
"scriptId": "29e202ac-98e3-4aa4-be0d-16f9d450cf31"
}
}
}'

image-20260802235712998
curl --request POST \
--url http://localhost:8888/api/orders/average \
--header 'Content-Type: application/json' \
--data '{
"items": [
{
"price": 100
},
{
"price": 300
}
]
}'

image-20260802235725129
Результат:
{
"averagePrice": 200,
"itemsCount": 2
}
А при пустом массиве items:
curl --request POST \
--url http://localhost:8888/api/orders/average \
--header 'Content-Type: application/json' \
--data '{"items": []}'
Результат — управляемая ошибка 400, а не падение скрипта с необработанным исключением:

image-20260802235829074
{
"error": "order has no items"
}
ForEach по массивуУ нас есть visitForEachStatement — цикл forEach по массиву. Почему именно forEach, а не произвольный while/for с условием:
forEach по массиву — число итераций жёстко равно itemsList.size(), то есть заранее известной, конечной длине списка, которая в свою очередь ограничена реальным размером тела HTTP-запроса. Бесконечный forEach физически невозможен — массив не может стать длиннее самого JSON, который прислал клиент.
Произвольный while (condition) или for (init; condition; step) — число итераций зависит от результата вычисления condition на каждом шаге, а это уже произвольная логика скрипта. Ошибка в условии (забыли i++, неправильно сформулировали выход) — и цикл выполняется вечно, съедая CPU потока в CompletableFuture.supplyAsync, пока таймаут EXECUTION_TIMEOUT_MS не прервёт Future — но сам поток при этом не обязательно остановится мгновенно.
Функции sum(), count(), avg() по сути делают то же самое, что можно было бы сделать через forEach вручную:
var total = 0;
for (var item of items) {
total = total + item.price;
}
Но sum(jsonField("items.price")) короче и не требует от пользователя писать даже безопасный forEach для типовой задачи — агрегированные функции — это просто более удобный, специализированный шорткат над той же самой идеей ограниченной итерации, а не альтернативная более безопасная замена циклам, как я сформулировал ранее. forEach и sum/count/avg решают одну и ту же категорию задач (обработка массива), просто на разных уровнях абстракции — forEach даёт гибкость для сложной кастомной логики на каждом элементе, а агрегированные функции — быстрый путь для самых частых операций без написания цикла вручную.
Пример 1 — скидка на товары:
Для сравнения два скрипта делающих одно и тоже. Одно с forEach, а второе с агрегированными функциями
var items = jsonField("items");
var total = 0;
for (var item of items) {
var discountedPrice = item.price * 0.9;
var total = total + discountedPrice;
}
return {
"status": 200,
"body": {
"originalItemsCount": count(items),
"totalAfterDiscount": total
}
}
var prices = jsonField("items[*].price");
var total = sum(prices) * 0.9;
return {
"status": 200,
"body": {
"originalItemsCount": count(prices),
"totalAfterDiscount": total
}
}
curl --request POST \
--url http://localhost:8888/__admin/extensions/wiremock-js/scripts \
--header 'Content-Type: application/json' \
--data '{
"name": "Apply discount to order items",
"sourceCode": "var items = jsonField(\"items\"); var total = 0; for (var item of items) { var discountedPrice = item.price * 0.9; var total = total + discountedPrice; } return { \"status\": 200, \"body\": { \"originalItemsCount\": count(items), \"totalAfterDiscount\": total } };"
}'

image-20260803000728764
curl --request POST \
--url http://localhost:8888/__admin/mappings \
--header 'Content-Type: application/json' \
--data '{
"request": {
"method": "POST",
"urlPath": "/api/orders/discount"
},
"response": {
"status": 200,
"transformers": [
"wiremock-js"
],
"transformerParameters": {
"scriptId": "62daaa03-d7fc-4c3b-b117-fd99086c788d"
}
}
}'

image-20260803000821075
curl --request POST \
--url http://localhost:8888/api/orders/discount \
--header 'Content-Type: application/json' \
--data '{
"items": [
{
"name": "Book",
"price": 100
},
{
"name": "Pen",
"price": 50
},
{
"name": "Notebook",
"price": 200
}
]
}'

image-20260803000856549
ЛимитыПочему именно такие лимиты? Значения 2000 символов и 5 уровней вложенности — не произвольные цифры, а результат простого расчёта: при лимите в 2000 символов физически невозможно объявить больше ~220 переменных (var x = ...; — минимум 9 символов на объявление), поэтому отдельный MAX_VARIABLES не добавляет никакой дополнительной защиты — лимит длины уже решает эту задачу. Похожая логика стоит за отказом от отдельного счётчика итераций цикла (как в MAX_LOOP_ITERATIONS у Salesforce Apex) — forEach в WiremockJs итерирует строго по размеру уже готового массива, и бесконечный цикл архитектурно исключён самой природой конструкции, а не отдельным лимитом сверху.
Вот сводная таблица ограничений WiremockJs
Ограничение | Значение | Где задаётся | Почему именно так |
|---|---|---|---|
Максимальная длина скрипта | 2000 символов |
| При такой длине физически нельзя объявить больше ~220 переменных, поэтому отдельный MAX_VARIABLES не нужен |
Максимальная глубина вложенности блоков | 5 уровней |
| Защита от искусственно раздутых деревьев разбора без лишней сложности парсера |
Таймаут выполнения скрипта | 100 мс |
| Достаточно даже с холодным стартом Faker после прогрева, но жёстко режет зависшие скрипты |
Циклы с произвольным условием | Отсутствуют (нет while, for с условием) | Грамматика (только | forEach ограничен размером готового массива — бесконечный цикл архитектурно исключён, а не просто запрещён лимитом |
Счётчик итераций цикла | Не нужен (в отличие от MAX_LOOP_ITERATIONS в Salesforce Apex) | — | Число итераций forEach равно |
Пользовательские функции / eval | Отсутствуют | Whitelist в | Только фиксированный набор встроенных функций — никакого произвольного кода на элемент массива |
Регулярные выражения | Только RE2J, без backreferences и части lookahead/lookbehind |
| Гарантированное линейное время O(n), исключает ReDoS через catastrophic backtracking |
Переименование ключей | Только по статическому словарю ( |
| Альтернатива map(item => …) означала бы добавление eval-семантики в язык |
Доступ к запросу | Только через |
| request.method и подобные обращения жёстко бросают исключение |
Проблема | Причина | Решение |
|---|---|---|
| DataFaker 2.7.0 скомпилирован под Java 17+, а контейнер WireMock — Java 11 | Downgrade на |
Таймаут 100 мс на первом вызове | Холодная загрузка YAML-провайдеров |
|
Таймаут 100 мс даже после прогрева | Отдельный холодный старт движка | Прогревочный скрипт теперь сам вызывает |
| Ошибка ручного редактирования скрипта после некорректного импорта в Insomnia | Проверка |
| Синтаксис DataFaker | Оборачивать имена провайдеров в |
Запустить Wiremock с раширением можно с помощью следующего docker-compose.yml
services:
wiremock:
image: wiremock/wiremock:3.13.1
container_name: wiremock-server
ports:
- "8888:8080"
volumes:
- ./wiremock/mappings:/home/wiremock/mappings
- ./wiremock/files:/home/wiremock/__files
- ./wiremock/extensions/wiremock-js-extension.jar:/var/wiremock/extensions/wiremock-js-extension.jar
- ./wiremock/scripts:/home/wiremock/scripts
environment:
- WIREMOCKJS_STORAGE_DIR=/home/wiremock/scripts
command:
- "--port=8080"
- "--verbose"
- "--local-response-templating"
- "--extensions=ru.mcs.wiremockjs.ScriptTransformer,ru.mcs.wiremockjs.admin.ScriptAdminApi"
ЗаключениеЭто было интересное приключение на несколько вечеров. А главное я получил инструмент который мне поможет упростить работу. Если данное расширение поможет еще кому-то то я буду только рад.
Ссылки| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | [Перевод] Пишем движок для JavaScript с нуля | 0 | 6.36 | 09-06-2026 |
| 2 | Как я посадил WireMock.Net на gRPC стероиды | 0 | 7.62 | 28-07-2026 |
| 3 | История JavaScript: браузерные войны, ECMAScript, Node.js, TypeScript, React | 0 | 8.31 | 10-07-2026 |
| 4 | Нейросеть-автопилот вместо 400 Playwright-тестов | 0 | 7 | 07-07-2026 |
| 5 | Point0 — фулстек TypeScript-фреймворк на Bun и React, о котором я мечтал | 5 | 7 | 01-07-2026 |
| 6 | Дайджест JS/TS: новинки ES2026, гонка рантаймов и EAP | 0 | 12.18 | 29-06-2026 |
| 7 | [Перевод] Языковая Модель без магии: Крошечная Language Model на чистом Node.js | 0 | 8.77 | 27-07-2026 |
| 8 | Литания: создаем свой язык программирования с лексером, парсером и интерпретатором | 0 | 9.54 | 02-03-2026 |
| 9 | Мне надоело писать один и тот же код. Поэтому я сделал Featuregen | 3 | 6 | 09-07-2026 |
| 10 | Как желание быстрее читать чужой код превратилось в войну с недетерминизмом LLM | 0 | 5 | 28-06-2026 |