Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
export type {
CompiledPathPattern,
PathMatchResult,
} from "./path-matcher.js";
export {
compilePathPattern,
isPathMatch,
matchPath,
} from "./path-matcher.js";

View File

@@ -0,0 +1,86 @@
# `path-matcher` — Hono-compatible path-pattern compilation and matching
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Compiles a Hono-style path pattern string into a regular expression
+ parameter-name list, then matches it against concrete inbound paths
and returns the captured params. The plugin pre-compiles every
`RouteRule.path` once at init so per-request route lookup is constant
time per rule and zero-allocation in the happy path.
The matcher accepts the same forms Hono's router accepts so route
rules written for the framework's router transparently apply to
rate-limit rules without translation. The compiled-pattern cache is
exposed so consumers wiring `RateLimitService.check(...)` manually
(without the middleware) can pre-compile their own rules.
## Features
+ Exact-literal, trailing wildcard (`/api/users/*`), inline segment
wildcard (`/api/*/avatar`), and named-param (`/api/users/:id`)
pattern forms.
+ Captured params returned as a frozen `Record<string, string>` so
callers can rely on referential stability.
+ All metacharacters in the literal portions are escaped before
pattern composition — `?`, `.`, `+` in a path do not turn into
regex operators by accident.
+ Defensive: every `RegExp` construction is wrapped so a future
pattern-syntax mistake surfaces as `RateLimitPluginError(INVALID_PATH_PATTERN)`
rather than a bare `SyntaxError`.
## Usage: Basic
```ts
import { isPathMatch } from "@suaveplan/api-plugin-rate-limit";
isPathMatch("/api/users/:id", "/api/users/123"); // true
isPathMatch("/api/users/:id", "/api/posts/123"); // false
```
## Usage: Advanced
```ts
import {
compilePathPattern,
matchPath,
} from "@suaveplan/api-plugin-rate-limit";
const pattern = compilePathPattern("/api/orgs/:org/users/:user");
const result = matchPath(pattern, "/api/orgs/acme/users/u42");
if (result.matched) {
console.log(result.params); // { org: "acme", user: "u42" }
}
```
## API Reference
+ `interface CompiledPathPattern` — `{ source, regex, paramNames }`.
+ `interface PathMatchResult` — `{ matched, params }`.
+ `function compilePathPattern(source: string): CompiledPathPattern` —
compile a pattern. Throws `RateLimitPluginError(INVALID_PATH_PATTERN)`
on regex construction failure.
+ `function matchPath(pattern: CompiledPathPattern, path: string): PathMatchResult`
— match a compiled pattern against a path.
+ `function isPathMatch(source: string, path: string): boolean` —
compile + match convenience helper.
## Implementation Notes
+ Pattern compilation is `O(n)` over the source length; matching is
`O(n)` over the path length once the regex is JIT'd by the engine.
+ Trailing `/*` matches zero or more path segments (the trailing slash
is optional), so `/api/users/*` matches both `/api/users/123` and
`/api/users/` — but not `/api/users` (no trailing slash). This
mirrors Hono's behavior.
+ Inline `*` matches exactly one segment (`[^/]+`) — `/api/*/avatar`
matches `/api/u1/avatar` but not `/api/u1/sub/avatar`.
+ Named params (`:name`) bind to the captured value; the value is
not currently used by the rate-limit service (rules apply to the
pattern as a whole) but is preserved so downstream consumers may
use it.
+ The matcher is universal — zero runtime dependencies.

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
} from "../error-codes/error-codes.js";
import { compilePathPattern, isPathMatch, matchPath } from "./path-matcher.js";
describe("compilePathPattern + matchPath", () => {
it("matches an exact literal", { timeout: 5000 }, () => {
const p = compilePathPattern("/api/users");
expect(matchPath(p, "/api/users").matched).toBe(true);
expect(matchPath(p, "/api/users/").matched).toBe(false);
expect(matchPath(p, "/api/posts").matched).toBe(false);
});
it("matches trailing wildcard sub-paths", { timeout: 5000 }, () => {
expect(isPathMatch("/api/users/*", "/api/users/42/profile")).toBe(true);
expect(isPathMatch("/api/users/*", "/api/users/")).toBe(true);
expect(isPathMatch("/api/users/*", "/api/posts")).toBe(false);
});
it("captures :id named param", { timeout: 5000 }, () => {
const p = compilePathPattern("/api/users/:id");
const result = matchPath(p, "/api/users/42");
expect(result.matched).toBe(true);
expect(result.params.id).toBe("42");
});
it("captures multiple named params", { timeout: 5000 }, () => {
const p = compilePathPattern("/api/orgs/:org/users/:user");
const r = matchPath(p, "/api/orgs/acme/users/u42");
expect(r.matched).toBe(true);
expect(r.params).toEqual({ org: "acme", user: "u42" });
});
it("inline segment wildcard binds exactly one segment", {
timeout: 5000,
}, () => {
expect(isPathMatch("/api/*/avatar", "/api/u1/avatar")).toBe(true);
expect(isPathMatch("/api/*/avatar", "/api/u1/sub/avatar")).toBe(false);
});
it("escapes regex metacharacters in literals", { timeout: 5000 }, () => {
expect(isPathMatch("/a.b", "/aXb")).toBe(false);
expect(isPathMatch("/a.b", "/a.b")).toBe(true);
});
it("throws INVALID_PATH_PATTERN when regex construction fails", {
timeout: 5000,
}, () => {
const originalRegExp = global.RegExp;
// Force RegExp to throw — defensive guard cover.
(global as { RegExp: unknown }).RegExp = function ThrowingRegExp() {
throw new SyntaxError("boom");
} as unknown as RegExpConstructor;
try {
let caught: RateLimitPluginError | undefined;
try {
compilePathPattern("/api/users");
} catch (err) {
caught = err as RateLimitPluginError;
}
expect(caught).toBeInstanceOf(RateLimitPluginError);
expect(caught?.code).toBe(
RATE_LIMIT_PLUGIN_CODES.INVALID_PATH_PATTERN
);
} finally {
(global as { RegExp: unknown }).RegExp = originalRegExp;
}
});
it("matchPath returns empty params on no match", { timeout: 5000 }, () => {
const p = compilePathPattern("/api/users/:id");
const r = matchPath(p, "/nope");
expect(r.matched).toBe(false);
expect(Object.keys(r.params)).toHaveLength(0);
});
});

View File

@@ -0,0 +1,109 @@
import {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
} from "../error-codes/error-codes.js";
/**
* A compiled path pattern ready for matching against inbound request
* paths. Created once per route rule at plugin init and reused per
* request.
*/
export interface CompiledPathPattern {
readonly source: string;
readonly regex: RegExp;
readonly paramNames: readonly string[];
}
/**
* Result of matching a compiled pattern against a path.
*/
export interface PathMatchResult {
readonly matched: boolean;
readonly params: Readonly<Record<string, string>>;
}
const PARAM_RE = /:[A-Za-z_][A-Za-z0-9_]*/g;
function escapeRegex(literal: string): string {
return literal.replace(/[.+?^${}()|[\]\\*]/g, "\\$&");
}
/**
* Compile a Hono-style path pattern into a `CompiledPathPattern`.
*
* Supported forms:
* - exact literal: `"/api/users"` matches only `/api/users`
* - trailing wildcard: `"/api/users/*"` matches anything under
* `/api/users/`
* - inline wildcard: `"/api/*"` matches one path segment
* - named param: `"/api/users/:id"` captures `id`
*
* Throws `RateLimitPluginError(INVALID_PATH_PATTERN)` when the
* compiled regex cannot be built — defensive guard so any future
* pattern-syntax mistake surfaces via the typed code rather than a
* bare `SyntaxError`.
*/
export function compilePathPattern(source: string): CompiledPathPattern {
const paramNames: string[] = [];
let cursor = 0;
let regexBody = "";
PARAM_RE.lastIndex = 0;
for (;;) {
const match = PARAM_RE.exec(source);
if (match === null) break;
const literal = source.slice(cursor, match.index);
regexBody += escapeRegex(literal).replace(/\\\*/g, "[^/]+");
const name = match[0].slice(1);
paramNames.push(name);
regexBody += "([^/]+)";
cursor = match.index + match[0].length;
}
const tail = source.slice(cursor);
if (tail.endsWith("/*")) {
regexBody += escapeRegex(tail.slice(0, -2)).replace(/\\\*/g, "[^/]+");
regexBody += "(?:/.*)?";
} else {
regexBody += escapeRegex(tail).replace(/\\\*/g, "[^/]+");
}
let regex: RegExp;
try {
regex = new RegExp(`^${regexBody}$`);
} catch (err) {
throw new RateLimitPluginError(
RATE_LIMIT_PLUGIN_CODES.INVALID_PATH_PATTERN,
`Failed to compile path pattern '${source}'`,
{ cause: err, context: { source, statusCode: 500 } }
);
}
return { source, regex, paramNames };
}
/**
* Test a compiled pattern against a concrete path. Returns a
* `PathMatchResult` carrying any captured params keyed by the names
* declared in the source pattern.
*/
export function matchPath(
pattern: CompiledPathPattern,
path: string
): PathMatchResult {
const match = pattern.regex.exec(path);
if (match === null) return { matched: false, params: Object.freeze({}) };
const params: Record<string, string> = {};
for (let i = 0; i < pattern.paramNames.length; i++) {
const name = pattern.paramNames[i] as string;
// `([^/]+)` is non-optional so a successful regex match guarantees
// every capture group resolved to a non-empty string.
params[name] = match[i + 1] as string;
}
return { matched: true, params: Object.freeze(params) };
}
/**
* Convenience helper: compile and match in a single call. Suitable for
* one-off tests; in production the plugin caches `CompiledPathPattern`
* per route rule.
*/
export function isPathMatch(source: string, path: string): boolean {
return matchPath(compilePathPattern(source), path).matched;
}