@feojs/regex
v0.0.1-alpha.0
Published
A safe, fast regex engine for JavaScript hotspots, powered by Rust and WebAssembly.
Maintainers
Readme
@feojs/regex
A safe, fast regex engine for JavaScript hotspots, powered by Rust and WebAssembly.
@feojs/regex is designed for user-controlled or performance-sensitive matching paths where JavaScript's backtracking RegExp engine can stall the event loop. It uses Rust's regex crate through WebAssembly, which means unsupported high-risk features such as look-around and backreferences fail at compile time instead of falling back to unsafe behavior.
Install
pnpm add @feojs/regexThis package is experimental and published under the alpha dist-tag while the first API is being validated.
Requirements:
- Node.js
>=18for the Node.js entry. - A bundler with top-level await support for
@feojs/regex/browser. - A server that can serve
.wasmassets.application/wasmis preferred for faster streaming initialization, but the browser entry falls back when the MIME type is not set.
Usage
Node.js:
import { Regex, RegexSet, isMatch } from "@feojs/regex";
const regex = new Regex("^(a+)+$");
const secrets = new RegexSet([
{ name: "password", pattern: "password\\s*=" },
{ name: "apiKey", pattern: "api[_-]?key\\s*=" },
{ name: "token", pattern: "token\\s*=" },
], "i");
regex.isMatch("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!");
secrets.matches("api-key=abc");
isMatch("\\bfeo\\w+", "Ship FeoJS today", "i");Browser/bundler:
import { Regex } from "@feojs/regex/browser";
const email = new Regex("[\\w.+-]+@[\\w.-]+\\.\\w+");
email.isMatch("[email protected]");Compiled regexes are the preferred API for hot paths:
import { Regex } from "@feojs/regex";
const email = new Regex("[\\w.+-]+@[\\w.-]+\\.\\w+");
for (const line of lines) {
if (email.isMatch(line)) {
// handle match
}
}API
const regex = new Regex(pattern, flags?);
const set = new RegexSet(patterns, flags?);
regex.isMatch(input);
regex.isFullMatch(input);
regex.fullMatch(input);
regex.test(input);
regex.find(input);
regex.findAll(input, options?);
regex.scan(input, options?);
regex.extract(input, options?);
regex.filter(inputs);
regex.reject(inputs);
regex.captures(input);
regex.capturesAll(input, options?);
regex.groups(input);
regex.groupsAll(input, options?);
regex.count(input);
regex.replaceAll(input, replacement);
regex.redact(input, replacement?);
regex.split(input, options?);
set.isMatch(input);
set.test(input);
set.matches(input);
set.classify(inputs, options?);
set.scanLines(input, options?);
set.scanFiles(files, options?);
set.filter(inputs);
set.reject(inputs);
set.redact(input, replacement?);
set.firstMatch(input);
set.matchedPatterns(input);
set.firstPattern(input);
set.matchedRules(input);
set.firstRule(input);
set.matchedIds(input);
set.firstId(input);
set.matchedNames(input);
set.firstName(input);Convenience helpers are available for one-off calls:
compile(pattern, flags?);
compileSet(patterns, flags?);
compileRules(config, flags?);
auditPatterns(patterns, flags?);
isMatch(pattern, input, flags?);
isFullMatch(pattern, input, flags?);
find(pattern, input, flags?);
findAll(pattern, input, flagsOrOptions?, options?);
scan(pattern, input, flagsOrOptions?, options?);
extract(pattern, input, flagsOrOptions?, options?);
filter(pattern, inputs, flags?);
reject(pattern, inputs, flags?);
captures(pattern, input, flags?);
capturesAll(pattern, input, flagsOrOptions?, options?);
groups(pattern, input, flags?);
groupsAll(pattern, input, flagsOrOptions?, options?);
count(pattern, input, flags?);
replaceAll(pattern, input, replacement, flags?);
redact(pattern, input, replacement?, flags?);
split(pattern, input, flagsOrOptions?, options?);
matchesAny(patterns, input, flags?);
matchesSet(patterns, input, flags?);
scanLines(patterns, input, flagsOrOptions?, options?);
scanFiles(patterns, files, flagsOrOptions?, options?);
summarizeScan(rows, options?);
toGitHubAnnotations(rows, options?);
toTextReport(rows, options?);
toSarif(rows, options?);
escape(input);Migration helpers:
isSupported(pattern, flags?);
tryCompile(pattern, flags?);
tryCompileSet(patterns, flags?);
tryCompileRules(config, flags?);
auditPatterns(patterns, flags?);tryCompile() returns { ok: true, regex } or { ok: false, error }. The error is a RegexSyntaxError with pattern, flags, and cause fields.
tryCompileSet() returns { ok: true, regexSet } or { ok: false, error }.
compileRules() accepts either an array of rules or { flags, rules }, matching the reusable JSON shape supported by the CLI. tryCompileRules() returns the same shape as tryCompileSet().
auditPatterns() returns { total, supported, unsupported, results } for migration reports.
Common Scenarios
Validation:
const slug = new Regex("[a-z0-9-]{1,80}");
slug.isFullMatch("feojs-regex");
slug.isFullMatch("prefix/feojs-regex");
// falseUse isFullMatch() for validation when the whole input must match. It avoids forcing every caller to remember anchors.
Batch validation:
const slug = new Regex("^[a-z0-9-]{1,80}$");
const candidates = ["feojs-regex", "invalid/slug", "also invalid", 2026];
slug.filter(candidates);
// ["feojs-regex", "2026"]
slug.reject(candidates);
// ["invalid/slug", "also invalid"]Use filter() and reject() when you already have an array of candidate strings to keep or drop, such as imported IDs, route params, log lines, and form values. They use isMatch() semantics, so anchor validation patterns when the whole input must match.
Migration checks:
const report = auditPatterns([
{ name: "slug", pattern: "[a-z0-9-]{1,80}" },
{ name: "legacy backreference", pattern: "(\\w+)\\1" },
]);
report.unsupported;
// 1For auditing many existing patterns, see docs/migration-guide.md and examples/audit-patterns.mjs.
Extraction with named groups:
const email = new Regex("(?<name>[a-z]+)@(?<host>[\\w.]+)");
email.groups("[email protected]");
// {
// name: { text: "carbon", start: 0, end: 6 },
// host: { text: "feojs.dev", start: 7, end: 16 }
// }Repeated extraction:
const email = new Regex("(?<name>[a-z]+)@(?<host>[\\w.]+)");
email.groupsAll("[email protected] [email protected]");
// [
// { name: { text: "carbon", start: 0, end: 6 }, host: { text: "feojs.dev", start: 7, end: 16 } },
// { name: { text: "ada", start: 21, end: 24 }, host: { text: "example.com", start: 25, end: 36 } }
// ]
email.groupsAll("[email protected] [email protected]", { limit: 1 });Scanning:
const token = new Regex("\\d+");
token.scan("v1 v22 v333");
// [
// { text: "1", start: 1, end: 2 },
// { text: "22", start: 4, end: 6 },
// { text: "333", start: 8, end: 11 }
// ]
token.scan("v1 v22 v333", { limit: 2 });
// [
// { text: "1", start: 1, end: 2 },
// { text: "22", start: 4, end: 6 }
// ]
token.extract("v1 v22 v333");
// ["1", "22", "333"]Use findAll() or scan() when you need offsets. Use extract() when you only need matched text. { limit } is useful when you only need a preview or the first few findings from large input. limit must be a non-negative integer.
Redaction:
const email = new Regex("[\\w.+-]+@[\\w.-]+\\.\\w+");
email.redact("email [email protected]");
// "email [REDACTED]"Delimiter splitting:
const delimiter = new Regex("[,;]\\s*");
delimiter.split("alpha, beta;gamma");
// ["alpha", "beta", "gamma"]
delimiter.split("alpha, beta;gamma", { limit: 2 });
// ["alpha", "beta;gamma"]Use split() for regex delimiters when you already chose this engine for predictable syntax and latency. limit means "return at most this many fields"; when the input has more delimiters, the last returned field keeps the remaining input.
Multi-rule scanning:
const secrets = new RegexSet(["password\\s*=", "api[_-]?key\\s*=", "token\\s*="], "i");
secrets.matches("token=abc password=def");
// [0, 2]
secrets.matchedPatterns("api-key=abc");
// ["api[_-]?key\\s*="]Named rules:
import { compileRules, summarizeScan, toGitHubAnnotations, toSarif, toTextReport } from "@feojs/regex";
const secrets = compileRules({
flags: "i",
rules: [
{ id: "secret.password", name: "password", pattern: "password\\s*=", severity: "high" },
{
id: "secret.api_key",
name: "apiKey",
pattern: "api[_-]?key\\s*=",
severity: "high",
message: "Potential API key assignment.",
tags: ["secret", "credential"]
},
{ id: "secret.token", name: "token", pattern: "token\\s*=", severity: "medium" },
],
});
secrets.matchedIds("api-key=abc token=def");
// ["secret.api_key", "secret.token"]
secrets.matchedNames("api-key=abc token=def");
// ["apiKey", "token"]
secrets.firstId("api-key=abc token=def");
// "secret.api_key"
secrets.firstName("api-key=abc token=def");
// "apiKey"
secrets.matchedRules("api-key=abc");
// [{
// index: 1,
// id: "secret.api_key",
// name: "apiKey",
// pattern: "api[_-]?key\\s*=",
// severity: "high",
// message: "Potential API key assignment.",
// tags: ["secret", "credential"]
// }]
secrets.filter(["ok", "api-key=abc", "token=def"]);
// ["api-key=abc", "token=def"]
secrets.classify(["ok", "api-key=abc token=def"], { matchedOnly: true });
// [
// {
// input: "api-key=abc token=def",
// matched: true,
// matches: [1, 2],
// ids: ["secret.api_key", "secret.token"],
// names: ["apiKey", "token"],
// firstId: "secret.api_key",
// firstName: "apiKey",
// ...
// }
// ]
secrets.scanLines("ok\napi-key=abc token=def", { limit: 1 });
// [
// {
// line: "api-key=abc token=def",
// lineNumber: 2,
// start: 3,
// end: 24,
// names: ["apiKey", "token"],
// ids: ["secret.api_key", "secret.token"],
// firstId: "secret.api_key",
// firstName: "apiKey",
// ...
// }
// ]Multi-file scans:
const rows = secrets.scanFiles([
{ uri: "src/app.log", input: appLog },
{ uri: "dist/app.log", input: builtLog },
{ uri: "src/worker.txt", input: workerText },
{ uri: "src/worker.log", input: workerLog },
], {
include: "*.log",
exclude: "dist/**",
limit: 100,
});
rows.map((row) => row.uri);
// ["src/app.log", "src/worker.log"]Precise findings:
const rows = secrets.scanLines("prefix api-key=abc token=def", { withMatches: true });
rows[0].findings;
// [
// { rule: { id: "secret.api_key", ... }, match: { text: "api-key=", start: 7, end: 15 } },
// { rule: { id: "secret.token", ... }, match: { text: "token=", start: 19, end: 25 } }
// ]Context lines:
const rows = secrets.scanLines("boot\nok\napi-key=abc\nnext", {
context: { before: 1, after: 1 },
});
rows[0].context;
// {
// before: [{ line: "ok", lineNumber: 2, start: 5, end: 7 }],
// after: [{ line: "next", lineNumber: 4, start: 20, end: 24 }]
// }CI/code scanning reports:
const rows = secrets.scanLines(logText);
const summary = summarizeScan(rows, { failOn: "warning" });
const annotations = toGitHubAnnotations(rows);
const text = toTextReport(rows);
const sarif = toSarif(rows, { uri: "app.log", toolName: "feojs-regex" });
summary.shouldFail;
// true when the scan contains warning-or-higher findings
sarif.version;
// "2.1.0"
annotations.split("\n")[0];
// "::error file=stdin,line=2,..."
text.split("\n")[0];
// "2 findings across 2 matched rows"RegexSet returns matches in rule order. Use firstRule(), firstId(), or firstName() when your rules are ordered by priority. Rule objects may include id, name, severity, message, tags, and metadata; these fields are preserved in matchedRules(), classify(), scanLines(), scanFiles(), summarizeScan(), SARIF reports, GitHub annotations, and text reports. A rule may also provide a string replacement for redaction; RegexSet#redact() applies rules in declaration order, with a rule-level replacement overriding the method fallback. Use filter() or reject() when you need to keep or drop log lines, events, routes, or imported records that match any rule. Use classify() when you need a per-input report with rule indexes, patterns, rule metadata, ids, names, and first-match fields. Pass withMatches: true to classify(), scanLines(), or scanFiles() when reports need exact matched spans. Pass context: n or context: { before, after } to scanLines() or scanFiles() when terminal output, review UIs, or audit screens need surrounding lines for triage. Use scanLines() when the input is already a log, config file, or multi-line report and callers need line numbers plus offsets. Use scanFiles() when a CLI, bundler plugin, or CI wrapper already has { uri, input } pairs and needs one row stream with optional include/exclude filtering. Use summarizeScan() when a CI job, audit screen, or CLI wrapper needs counts by severity, rule, and file plus a shouldFail decision. Use toGitHubAnnotations() when a GitHub Actions job should emit inline workflow annotations without SARIF upload setup. Use toTextReport() when a local CLI or script should print grep-like findings for humans. Use Regex when you need match text, offsets, captures, groups, or replacement.
Use compileRules() when the same rule config should be shared between app code, browser tooling, and the CLI. Passing an explicit second flags argument overrides config.flags.
CLI
The package also ships a small Node.js CLI for log and text scans:
feojs-regex scan-lines --rule 'apiKey=api[_-]?key\s*=' --flags i app.log worker.logIt prints JSON reports from RegexSet#scanLines() and adds a uri field for each input file. Omit files or pass - to read stdin:
cat app.log | feojs-regex scan-lines --rule 'apiKey=api[_-]?key\s*=' --jsonlFor reusable scans, keep rules in JSON:
{
"flags": "i",
"rules": [
{
"id": "secret.api_key",
"name": "apiKey",
"pattern": "api[_-]?key\\s*=",
"severity": "high",
"message": "Potential API key assignment.",
"tags": ["secret", "credential"]
},
{ "id": "secret.bearer", "name": "bearer", "pattern": "bearer\\s+[A-Za-z0-9._-]+", "severity": "high" }
]
}feojs-regex scan-lines --rules rules.json app.log worker.log
feojs-regex scan-lines --rules rules.json --include '*.log' --exclude 'dist/**' app.log dist/app.log worker.log
feojs-regex scan-lines --rules rules.json --context 2 --text app.log worker.log
feojs-regex scan-lines --rules rules.json --text app.log worker.log
feojs-regex scan-lines --rules rules.json --sarif app.log worker.log > results.sarif
feojs-regex scan-lines --rules rules.json --github --fail-on warning app.log worker.log
feojs-regex redact --rules rules.json app.log > redacted.log
cat app.log | feojs-regex redact --rules rules.json --replacement '[MASKED]'Useful options:
--rule name=pattern: add a named rule--pattern pattern: add an unnamed pattern--rules file: load a JSON array of rules or{ "flags", "rules" }--flags flags: shared flags for all rules--all: include unmatched lines--limit n: return at mostnrows--include glob: scan only file paths matchingglob; repeatable--exclude glob: skip file paths matchingglob; repeatable--matches: include precise finding spans in JSON or JSONL rows--context n,-C n: includenlines before and after each matched line--before-context n,-B n: includenlines before each matched line--after-context n,-A n: includenlines after each matched line--jsonl: print one JSON object per line--text: print a human-readable text report--summary: print a JSON summary instead of row-level results--sarif: print a SARIF 2.1.0 report--github: print GitHub Actions workflow annotations--fail-on level: exit 1 when findings meeterror,warning,note, ornone
When multiple files are provided, --limit applies to the combined output in argument order. --include and --exclude use *, ?, and **; patterns without / match the basename, so *.log matches src/app.log. SARIF, GitHub annotation, and text output automatically use precise finding spans. Use --matches when JSON or JSONL output should include the same span data. Context options add structured context to JSON/JSONL rows and grep-like context lines to text output; SARIF and GitHub annotations keep their standard output shape.
For CI gates, combine --summary and --fail-on:
feojs-regex scan-lines --rules rules.json --summary --fail-on warning app.log worker.logSupported Flags
Supported flags:
i: case-insensitivem: multi-lines: dot matches newlineu: accepted because Rust regex is Unicode-aware by defaultg,y: accepted for migration, but treated as iteration semantics rather than pattern semantics
Unsupported syntax throws during compilation. This is intentional. The package should not silently fall back to JavaScript RegExp, because predictable matching behavior is the product promise.
Benchmarks
pnpm install
pnpm benchpnpm bench also writes a machine-readable report to tmp/benchmarks/latest.json.
For JSON on stdout:
pnpm bench:jsonTo verify the npm package contents before publishing:
pnpm pack:checkTo verify the packed tarball in a clean consumer project:
pnpm smoke:packFor a slower headline stress case:
REDOS_SIZE=30 pnpm benchThe benchmark suite includes:
- a catastrophic-backtracking style miss:
^(a+)+$against manyacharacters plus! - an email-like match
- a supported pattern audit batch
- a rules config compile pass
- a numeric token count over large input
- a full token scan over large input
- a text-only token extraction over large input
- a limited token scan over large input
- a full-match slug validation batch
- a batch slug filtering pass
- a named capture extraction batch
- a delimiter normalization split
- a 130-rule secret scan using
RegexSet - a priority rule classification using
RegexSet#firstName - a multi-rule line filtering pass using
RegexSet#filter - a multi-rule line classification pass using
RegexSet#classify - a multi-rule log scan using
RegexSet#scanLines - a multi-rule precise log scan using
RegexSet#scanLineswithwithMatches - a multi-rule contextual log scan using
RegexSet#scanLineswithcontext - a multi-rule redaction pass using
RegexSet#redact - a multi-file filtered scan using
RegexSet#scanFiles - a multi-rule SARIF report using
RegexSet#scanLinesandtoSarif - a multi-rule scan summary using
RegexSet#scanLinesandsummarizeScan - a multi-rule GitHub annotation report using
RegexSet#scanLinesandtoGitHubAnnotations - a multi-rule text report using
RegexSet#scanLinesandtoTextReport
The ReDoS-style benchmark is the headline case, but ordinary scan cases are kept in the suite so API and WASM boundary costs stay visible.
Runtime Targets
Current package targets:
- Node.js:
@feojs/regex - Browser/bundler:
@feojs/regex/browser
Bundler support is verified with a Vite fixture. The browser entry uses the wasm-pack --target web output and initializes the WASM module with top-level await.
The release smoke test installs the packed tarball into a temporary consumer project and verifies:
- Node.js ESM import from
@feojs/regex - Node.js CommonJS require from
@feojs/regex - TypeScript
NodeNexttype checking for@feojs/regexand@feojs/regex/browser - Vite browser build from
@feojs/regex/browser
See docs/release-readiness.md for the current release checklist and non-goals.
