@c9up/rune 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Schema.d.ts +65 -7
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +106 -26
- package/dist/Schema.js.map +1 -1
- package/dist/formats.d.ts +2 -3
- package/dist/formats.d.ts.map +1 -1
- package/dist/formats.js +66 -7
- package/dist/formats.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +18 -6
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +34 -19
- package/dist/native.js.map +1 -1
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/src/Schema.ts +176 -40
- package/src/formats.ts +61 -6
- package/src/index.ts +7 -0
- package/src/native.ts +36 -21
package/src/Schema.ts
CHANGED
|
@@ -54,9 +54,9 @@ import {
|
|
|
54
54
|
readHead,
|
|
55
55
|
} from "./magic.js";
|
|
56
56
|
import {
|
|
57
|
+
assertNativeAvailable,
|
|
57
58
|
isNativeAvailable,
|
|
58
59
|
validateNative,
|
|
59
|
-
warnNativeUnavailableOnce,
|
|
60
60
|
} from "./native.js";
|
|
61
61
|
|
|
62
62
|
export type ValidationMessageParams = Record<string, string | number | boolean>;
|
|
@@ -188,6 +188,18 @@ export interface CreateRuleOptions {
|
|
|
188
188
|
isAsync?: boolean;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// `isAsync: true` genuinely produces an AsyncCompiledRule — a different
|
|
192
|
+
// discriminant (`__rune: "asyncRule"`) that `.use()` routes to the awaited
|
|
193
|
+
// register. Saying otherwise, as a cast did, told the compiler the opposite of
|
|
194
|
+
// what runs.
|
|
195
|
+
export function createRule(
|
|
196
|
+
validator: AsyncRuleValidator<undefined>,
|
|
197
|
+
options: CreateRuleOptions & { isAsync: true },
|
|
198
|
+
): () => AsyncCompiledRule;
|
|
199
|
+
export function createRule<Options>(
|
|
200
|
+
validator: AsyncRuleValidator<Options>,
|
|
201
|
+
options: CreateRuleOptions & { isAsync: true },
|
|
202
|
+
): (options: Options) => AsyncCompiledRule;
|
|
191
203
|
export function createRule(
|
|
192
204
|
validator: RuleValidator<undefined>,
|
|
193
205
|
options?: CreateRuleOptions,
|
|
@@ -197,18 +209,18 @@ export function createRule<Options>(
|
|
|
197
209
|
options?: CreateRuleOptions,
|
|
198
210
|
): (options: Options) => CompiledRule;
|
|
199
211
|
export function createRule<Options>(
|
|
200
|
-
validator: RuleValidator<Options>,
|
|
212
|
+
validator: RuleValidator<Options> | AsyncRuleValidator<Options>,
|
|
201
213
|
ruleOptions?: CreateRuleOptions,
|
|
202
|
-
): (options: Options) => CompiledRule {
|
|
214
|
+
): (options: Options) => CompiledRule | AsyncCompiledRule {
|
|
203
215
|
if (ruleOptions?.isAsync) {
|
|
204
216
|
// VineJS expresses "async" as an option on createRule, so honour it by
|
|
205
217
|
// BUILDING the async rule rather than refusing: `.use()` routes an
|
|
206
218
|
// async-marked rule to the awaited register.
|
|
207
|
-
const asyncBuilder = createAsyncRule(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
);
|
|
211
|
-
return asyncBuilder
|
|
219
|
+
const asyncBuilder = createAsyncRule(validator, {
|
|
220
|
+
...ruleOptions,
|
|
221
|
+
isAsync: undefined,
|
|
222
|
+
});
|
|
223
|
+
return asyncBuilder;
|
|
212
224
|
}
|
|
213
225
|
return (options: Options): CompiledRule => ({
|
|
214
226
|
__rune: "rule",
|
|
@@ -816,6 +828,21 @@ export interface ConditionalGroup {
|
|
|
816
828
|
}>;
|
|
817
829
|
}
|
|
818
830
|
|
|
831
|
+
/**
|
|
832
|
+
* Called when no union branch matched (VineJS `UnionNoMatchCallback`). Report
|
|
833
|
+
* through the field context; reporting nothing suppresses the generic error.
|
|
834
|
+
*/
|
|
835
|
+
export type UnionNoMatchCallback = (
|
|
836
|
+
value: unknown,
|
|
837
|
+
field: FieldContext,
|
|
838
|
+
) => void;
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Called with a record's keys (VineJS `RecordKeysCallback`). Report through the
|
|
842
|
+
* field context; reporting nothing accepts the key set.
|
|
843
|
+
*/
|
|
844
|
+
export type RecordKeysCallback = (keys: string[], field: FieldContext) => void;
|
|
845
|
+
|
|
819
846
|
/** Per-field introspection returned inside `toJSON().schema`. */
|
|
820
847
|
export type SchemaIntrospection = Record<
|
|
821
848
|
string,
|
|
@@ -1405,10 +1432,10 @@ export function schema(
|
|
|
1405
1432
|
reporterError = nativeReporter?.createError;
|
|
1406
1433
|
return native;
|
|
1407
1434
|
}
|
|
1408
|
-
// This schema
|
|
1409
|
-
//
|
|
1410
|
-
//
|
|
1411
|
-
|
|
1435
|
+
// This schema carries nothing the engine cannot run, so the engine is
|
|
1436
|
+
// what must run it. Falling back to the TypeScript validator here made
|
|
1437
|
+
// the verdict depend on whether a binary loaded.
|
|
1438
|
+
assertNativeAvailable();
|
|
1412
1439
|
}
|
|
1413
1440
|
|
|
1414
1441
|
const errors: ValidationError[] = [];
|
|
@@ -1739,7 +1766,12 @@ export interface RuleDef {
|
|
|
1739
1766
|
param?: number;
|
|
1740
1767
|
/** Interpolation args exposed to i18n/messages providers (e.g. `{ min: 3 }`). */
|
|
1741
1768
|
args?: Record<string, unknown>;
|
|
1742
|
-
|
|
1769
|
+
/**
|
|
1770
|
+
* `field` is the context VineJS hands a rule — a callback-valued rule
|
|
1771
|
+
* (`in`, `notIn`, `enum`) reads `meta`/`parent` off it to compute its list
|
|
1772
|
+
* per request. Rules that do not need it simply declare one parameter.
|
|
1773
|
+
*/
|
|
1774
|
+
validate: (value: unknown, field: FieldContext) => boolean;
|
|
1743
1775
|
message: string;
|
|
1744
1776
|
/** Set when `.message()` overrode this rule's default text. */
|
|
1745
1777
|
hasCustomMessage?: boolean;
|
|
@@ -1776,12 +1808,12 @@ const UUID_RE =
|
|
|
1776
1808
|
*/
|
|
1777
1809
|
export type AllowedValues =
|
|
1778
1810
|
| ReadonlyArray<string | number | boolean>
|
|
1779
|
-
| (() => ReadonlyArray<string | number | boolean>);
|
|
1811
|
+
| ((field: FieldContext) => ReadonlyArray<string | number | boolean>);
|
|
1780
1812
|
|
|
1781
1813
|
/** Normalise a static list or a callback into a getter. */
|
|
1782
1814
|
function allowedValuesResolver(
|
|
1783
1815
|
values: AllowedValues,
|
|
1784
|
-
): () => ReadonlyArray<string | number | boolean> {
|
|
1816
|
+
): (field: FieldContext) => ReadonlyArray<string | number | boolean> {
|
|
1785
1817
|
if (typeof values === "function") return values;
|
|
1786
1818
|
const snapshot = [...values];
|
|
1787
1819
|
return () => snapshot;
|
|
@@ -1829,8 +1861,14 @@ export class RuleChain<Output = unknown> {
|
|
|
1829
1861
|
#camelCaseKeys = false;
|
|
1830
1862
|
#groups: ConditionalGroup[] = [];
|
|
1831
1863
|
#recordValueChain: RuleChain | null = null;
|
|
1864
|
+
#enumChoices:
|
|
1865
|
+
| ReadonlyArray<string | number | boolean>
|
|
1866
|
+
| ((field: FieldContext) => ReadonlyArray<string | number | boolean>)
|
|
1867
|
+
| null = null;
|
|
1868
|
+
#recordKeysCheck: RecordKeysCallback | null = null;
|
|
1832
1869
|
#tupleChains: RuleChain[] | null = null;
|
|
1833
1870
|
#unionChains: ConditionalBranch[] | null = null;
|
|
1871
|
+
#unionNoMatch: UnionNoMatchCallback | null = null;
|
|
1834
1872
|
#useRules: CompiledRule[] = [];
|
|
1835
1873
|
#asyncRules: AsyncCompiledRule[] = [];
|
|
1836
1874
|
/** Last rule added, whichever register it landed in — the `message()` target. */
|
|
@@ -1950,8 +1988,11 @@ export class RuleChain<Output = unknown> {
|
|
|
1950
1988
|
next.#camelCaseKeys = this.#camelCaseKeys;
|
|
1951
1989
|
next.#groups = [...this.#groups];
|
|
1952
1990
|
next.#recordValueChain = this.#recordValueChain;
|
|
1991
|
+
next.#enumChoices = this.#enumChoices;
|
|
1992
|
+
next.#recordKeysCheck = this.#recordKeysCheck;
|
|
1953
1993
|
next.#tupleChains = this.#tupleChains;
|
|
1954
1994
|
next.#unionChains = this.#unionChains;
|
|
1995
|
+
next.#unionNoMatch = this.#unionNoMatch;
|
|
1955
1996
|
next.#ruleMessages = new Map(this.#ruleMessages);
|
|
1956
1997
|
next.#lastRule = this.#lastRule;
|
|
1957
1998
|
next.#preTransforms = [...this.#preTransforms];
|
|
@@ -2406,6 +2447,18 @@ export class RuleChain<Output = unknown> {
|
|
|
2406
2447
|
return this.#retype<Record<string, OutputOf<Item>>>();
|
|
2407
2448
|
}
|
|
2408
2449
|
|
|
2450
|
+
/**
|
|
2451
|
+
* Check the record's KEYS, not its values (VineJS `record().validateKeys()`).
|
|
2452
|
+
*
|
|
2453
|
+
* The callback receives every key at once and reports through the field
|
|
2454
|
+
* context — the set is what matters when keys must be exclusive, exhaustive,
|
|
2455
|
+
* or drawn from a list only known at runtime.
|
|
2456
|
+
*/
|
|
2457
|
+
validateKeys(callback: RecordKeysCallback): this {
|
|
2458
|
+
this.#recordKeysCheck = callback;
|
|
2459
|
+
return this;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2409
2462
|
/**
|
|
2410
2463
|
* Fixed-length array with a schema per position (VineJS `tuple`). Extra
|
|
2411
2464
|
* items are rejected — a tuple that silently ignores a trailing element is
|
|
@@ -2436,6 +2489,20 @@ export class RuleChain<Output = unknown> {
|
|
|
2436
2489
|
* - bare chains: tried in order, first match wins, and a total miss reports a
|
|
2437
2490
|
* single `union` error rather than every losing branch's noise.
|
|
2438
2491
|
*/
|
|
2492
|
+
/**
|
|
2493
|
+
* What to do when NO union branch matched (VineJS `union().otherwise()`).
|
|
2494
|
+
*
|
|
2495
|
+
* The callback receives the value and the field, and reports the error it
|
|
2496
|
+
* wants — the point being that "matches nothing" is a useless message when
|
|
2497
|
+
* the caller knows which shapes were on offer. Reporting nothing from the
|
|
2498
|
+
* callback suppresses the generic error entirely, which is how a union
|
|
2499
|
+
* folded into a larger check stays quiet.
|
|
2500
|
+
*/
|
|
2501
|
+
otherwise(callback: UnionNoMatchCallback): this {
|
|
2502
|
+
this.#unionNoMatch = callback;
|
|
2503
|
+
return this;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2439
2506
|
union(chains: readonly UnionBranch[]): this {
|
|
2440
2507
|
this.#unionChains = chains.map(toUnionBranch);
|
|
2441
2508
|
// Marker rule: its name is not in NATIVE_RULES, which is what keeps a
|
|
@@ -2669,20 +2736,45 @@ export class RuleChain<Output = unknown> {
|
|
|
2669
2736
|
return this;
|
|
2670
2737
|
}
|
|
2671
2738
|
|
|
2672
|
-
/**
|
|
2739
|
+
/**
|
|
2740
|
+
* Must equal one of `values` (enum). Narrows the output to the union.
|
|
2741
|
+
*
|
|
2742
|
+
* `values` may be a callback receiving the field, which is how a list that
|
|
2743
|
+
* depends on the request — the roles this tenant allows, the statuses this
|
|
2744
|
+
* user may set — is computed per validation instead of frozen at import.
|
|
2745
|
+
*/
|
|
2673
2746
|
enum<const V extends readonly (string | number | boolean)[]>(
|
|
2674
|
-
values: V,
|
|
2747
|
+
values: V | ((field: FieldContext) => V),
|
|
2675
2748
|
): RuleChain<V[number]> {
|
|
2676
|
-
const
|
|
2749
|
+
const lazy = typeof values === "function";
|
|
2750
|
+
const resolve = allowedValuesResolver(values);
|
|
2751
|
+
this.#enumChoices = lazy ? values : [...values];
|
|
2677
2752
|
this.#pushRule({
|
|
2678
2753
|
name: "enum",
|
|
2679
|
-
args: { values:
|
|
2680
|
-
|
|
2754
|
+
args: lazy ? {} : { values: [...values] },
|
|
2755
|
+
// A computed list is per-call; the native engine only ever sees a
|
|
2756
|
+
// static array, so it must not run this rule.
|
|
2757
|
+
tsOnly: lazy,
|
|
2758
|
+
validate: (v, field) => resolve(field).includes(asPrimitive(v)),
|
|
2681
2759
|
message: "Invalid value",
|
|
2682
2760
|
});
|
|
2683
2761
|
return this.#retype<V[number]>();
|
|
2684
2762
|
}
|
|
2685
2763
|
|
|
2764
|
+
/**
|
|
2765
|
+
* The choices this enum was declared with (VineJS `getChoices()`) — the list
|
|
2766
|
+
* itself, or the callback when it is computed per request.
|
|
2767
|
+
*
|
|
2768
|
+
* Reading them back is what lets a form render the same options the
|
|
2769
|
+
* validator will accept, from one declaration instead of two.
|
|
2770
|
+
*/
|
|
2771
|
+
getChoices():
|
|
2772
|
+
| ReadonlyArray<string | number | boolean>
|
|
2773
|
+
| ((field: FieldContext) => ReadonlyArray<string | number | boolean>)
|
|
2774
|
+
| undefined {
|
|
2775
|
+
return this.#enumChoices ?? undefined;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2686
2778
|
/** Must equal a literal value. */
|
|
2687
2779
|
literal<V extends string | number | boolean>(value: V): RuleChain<V> {
|
|
2688
2780
|
this.#pushRule({
|
|
@@ -2922,8 +3014,13 @@ export class RuleChain<Output = unknown> {
|
|
|
2922
3014
|
}
|
|
2923
3015
|
|
|
2924
3016
|
/**
|
|
2925
|
-
* Must be a mobile number
|
|
2926
|
-
*
|
|
3017
|
+
* Must be a mobile number (VineJS/Adonis `mobile()`).
|
|
3018
|
+
*
|
|
3019
|
+
* With no `locale`, the number must be in E.164 form. With one or more,
|
|
3020
|
+
* it must match one of their numbering plans, as in VineJS. rune carries
|
|
3021
|
+
* its own plans rather than validator.js', so it knows fewer locales — an
|
|
3022
|
+
* unknown one raises `UNSUPPORTED_LOCALE` at schema build, naming the ones
|
|
3023
|
+
* it does know, rather than silently accepting anything at request time.
|
|
2927
3024
|
*/
|
|
2928
3025
|
mobile(options?: { locale?: string | string[]; strictMode?: boolean }): this {
|
|
2929
3026
|
const locales = options?.locale ? [options.locale].flat() : null;
|
|
@@ -3308,7 +3405,7 @@ export class RuleChain<Output = unknown> {
|
|
|
3308
3405
|
// A callback list is computed per call — the native engine only ever
|
|
3309
3406
|
// sees a static array, so it must not run this rule.
|
|
3310
3407
|
tsOnly: typeof values === "function",
|
|
3311
|
-
validate: (v) => resolve().includes(asPrimitive(v)),
|
|
3408
|
+
validate: (v, field) => resolve(field).includes(asPrimitive(v)),
|
|
3312
3409
|
message: "Invalid value",
|
|
3313
3410
|
});
|
|
3314
3411
|
return this;
|
|
@@ -3321,7 +3418,7 @@ export class RuleChain<Output = unknown> {
|
|
|
3321
3418
|
name: "notIn",
|
|
3322
3419
|
args: typeof values === "function" ? {} : { values: [...values] },
|
|
3323
3420
|
tsOnly: typeof values === "function",
|
|
3324
|
-
validate: (v) => !resolve().includes(asPrimitive(v)),
|
|
3421
|
+
validate: (v, field) => !resolve(field).includes(asPrimitive(v)),
|
|
3325
3422
|
message: "Invalid value",
|
|
3326
3423
|
});
|
|
3327
3424
|
return this;
|
|
@@ -3904,6 +4001,14 @@ export class RuleChain<Output = unknown> {
|
|
|
3904
4001
|
}
|
|
3905
4002
|
|
|
3906
4003
|
// 4b. Record values — same shape as the nested-object walk, arbitrary keys.
|
|
4004
|
+
if (this.#recordKeysCheck && isPlainObject(transformed)) {
|
|
4005
|
+
// Keys first: a key-level rule that rejects the shape makes the
|
|
4006
|
+
// per-value errors that would follow noise.
|
|
4007
|
+
this.#recordKeysCheck(
|
|
4008
|
+
Object.keys(transformed),
|
|
4009
|
+
this.#makeFieldContext(field, transformed, ctx, errors, () => {}),
|
|
4010
|
+
);
|
|
4011
|
+
}
|
|
3907
4012
|
if (this.#recordValueChain && isPlainObject(transformed)) {
|
|
3908
4013
|
const obj: Record<string, unknown> = { ...transformed };
|
|
3909
4014
|
transformed = obj;
|
|
@@ -3985,19 +4090,30 @@ export class RuleChain<Output = unknown> {
|
|
|
3985
4090
|
}
|
|
3986
4091
|
}
|
|
3987
4092
|
if (!matched) {
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
4093
|
+
if (this.#unionNoMatch) {
|
|
4094
|
+
// The callback owns the reporting: whatever it pushes is the
|
|
4095
|
+
// error, and pushing nothing means it handled the case itself.
|
|
4096
|
+
const reported: ValidationError[] = [];
|
|
4097
|
+
this.#unionNoMatch(
|
|
4098
|
+
transformed,
|
|
4099
|
+
this.#makeFieldContext(field, transformed, ctx, reported, () => {}),
|
|
4100
|
+
);
|
|
4101
|
+
errors.push(...reported);
|
|
4102
|
+
} else {
|
|
4103
|
+
errors.push({
|
|
3992
4104
|
field,
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4105
|
+
rule: "union",
|
|
4106
|
+
message: resolveRuleMessage(
|
|
4107
|
+
field,
|
|
4108
|
+
{
|
|
4109
|
+
name: "union",
|
|
4110
|
+
validate: () => false,
|
|
4111
|
+
message: "Does not match any allowed shape",
|
|
4112
|
+
},
|
|
4113
|
+
ctx,
|
|
4114
|
+
),
|
|
4115
|
+
});
|
|
4116
|
+
}
|
|
4001
4117
|
}
|
|
4002
4118
|
}
|
|
4003
4119
|
|
|
@@ -4126,8 +4242,14 @@ export class RuleChain<Output = unknown> {
|
|
|
4126
4242
|
value: unknown,
|
|
4127
4243
|
ctx: RunContext,
|
|
4128
4244
|
): ValidationError | null {
|
|
4245
|
+
let context: FieldContext | undefined;
|
|
4246
|
+
const fieldContext = (): FieldContext =>
|
|
4247
|
+
(context ??= this.#makeFieldContext(field, value, ctx, [], () => {}));
|
|
4129
4248
|
for (const rule of this.#rules) {
|
|
4130
|
-
if (
|
|
4249
|
+
if (
|
|
4250
|
+
TYPE_RULE_NAMES.has(rule.name) &&
|
|
4251
|
+
!rule.validate(value, fieldContext())
|
|
4252
|
+
) {
|
|
4131
4253
|
return {
|
|
4132
4254
|
field,
|
|
4133
4255
|
rule: rule.name,
|
|
@@ -4146,10 +4268,21 @@ export class RuleChain<Output = unknown> {
|
|
|
4146
4268
|
ctx: RunContext,
|
|
4147
4269
|
): ValidationError[] {
|
|
4148
4270
|
const errors: ValidationError[] = [];
|
|
4271
|
+
// Built once and only if a rule actually reads it: most rules take the
|
|
4272
|
+
// value alone, and a context per rule per field is pure waste.
|
|
4273
|
+
let context: FieldContext | undefined;
|
|
4274
|
+
const fieldContext = (): FieldContext =>
|
|
4275
|
+
(context ??= this.#makeFieldContext(
|
|
4276
|
+
field,
|
|
4277
|
+
transformed,
|
|
4278
|
+
ctx,
|
|
4279
|
+
[],
|
|
4280
|
+
() => {},
|
|
4281
|
+
));
|
|
4149
4282
|
for (const rule of this.#rules) {
|
|
4150
4283
|
if (TYPE_RULE_NAMES.has(rule.name)) continue;
|
|
4151
4284
|
if (this.#bail && errors.length > 0) break;
|
|
4152
|
-
if (!rule.validate(transformed)) {
|
|
4285
|
+
if (!rule.validate(transformed, fieldContext())) {
|
|
4153
4286
|
errors.push({
|
|
4154
4287
|
field,
|
|
4155
4288
|
rule: rule.name,
|
|
@@ -4400,7 +4533,10 @@ export const rules = {
|
|
|
4400
4533
|
const typeRule = chain.rules.find((rule) =>
|
|
4401
4534
|
TYPE_RULE_NAMES.has(rule.name),
|
|
4402
4535
|
);
|
|
4403
|
-
return unionIf(
|
|
4536
|
+
return unionIf(
|
|
4537
|
+
(value, field) => typeRule?.validate(value, field) === true,
|
|
4538
|
+
chain,
|
|
4539
|
+
);
|
|
4404
4540
|
}),
|
|
4405
4541
|
);
|
|
4406
4542
|
},
|
|
@@ -4410,7 +4546,7 @@ export const rules = {
|
|
|
4410
4546
|
array: <Item extends RuleChain>(item?: Item): RuleChain<OutputOf<Item>[]> =>
|
|
4411
4547
|
new RuleChain().array(item),
|
|
4412
4548
|
enum: <const V extends readonly (string | number | boolean)[]>(
|
|
4413
|
-
values: V,
|
|
4549
|
+
values: V | ((field: FieldContext) => V),
|
|
4414
4550
|
): RuleChain<V[number]> => new RuleChain().enum(values),
|
|
4415
4551
|
literal: <V extends string | number | boolean>(value: V): RuleChain<V> =>
|
|
4416
4552
|
new RuleChain().literal(value),
|
package/src/formats.ts
CHANGED
|
@@ -157,9 +157,8 @@ export function isPostalCode(v: string, countryCode: string): boolean | null {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
|
-
* Mobile numbers in E.164 form
|
|
161
|
-
*
|
|
162
|
-
* needs one writes a `.regex()` or a custom rule.
|
|
160
|
+
* Mobile numbers in E.164 form — the locale-less check. Per-locale plans live
|
|
161
|
+
* in `MOBILE_LOCALES`; see {@link isMobileForLocale}.
|
|
163
162
|
*/
|
|
164
163
|
export const isMobile = (v: string): boolean =>
|
|
165
164
|
E164_RE.test(v.replace(/[ .-]/g, ""));
|
|
@@ -490,12 +489,62 @@ export interface EmailOptions {
|
|
|
490
489
|
domain_specific_validation?: boolean;
|
|
491
490
|
}
|
|
492
491
|
|
|
492
|
+
/** Hard bound on anything handed to the email parser (RFC 5322 line limit). */
|
|
493
|
+
const MAX_EMAIL_INPUT = 998;
|
|
494
|
+
|
|
493
495
|
/** Unquoted local part: dot-separated atoms of RFC 5322 atext. */
|
|
494
496
|
const ATEXT = "[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+";
|
|
495
497
|
const DOT_ATOM_RE = new RegExp(`^${ATEXT}(?:\\.${ATEXT})*$`);
|
|
496
498
|
/** Quoted local part: `"anything but bare quote/backslash, or escaped"`. */
|
|
497
499
|
const QUOTED_LOCAL_RE = /^"(?:[^"\\]|\\.)*"$/;
|
|
498
|
-
|
|
500
|
+
/**
|
|
501
|
+
* Split `Display Name <address@host>` into its address.
|
|
502
|
+
*
|
|
503
|
+
* Parsed rather than matched: the obvious pattern —
|
|
504
|
+
* `^\s*(?:"..."|[^<>@]*?)\s*<(.+)>\s*$` — lets `\s*` and `[^<>@]*?` both
|
|
505
|
+
* claim a space, so an input of N spaces has N ways to be split and the engine
|
|
506
|
+
* tries them all. Measured at O(n³): 8 KB of spaces blocked the event loop for
|
|
507
|
+
* 67 seconds, which turns any route validating an email into a denial of
|
|
508
|
+
* service. This walk is linear and answers the same question.
|
|
509
|
+
*
|
|
510
|
+
* Returns the address, or null when the input is not in display-name form.
|
|
511
|
+
*/
|
|
512
|
+
function displayNameAddress(input: string): string | null {
|
|
513
|
+
const trimmed = input.trim();
|
|
514
|
+
if (!trimmed.endsWith(">")) return null;
|
|
515
|
+
|
|
516
|
+
let open: number;
|
|
517
|
+
if (trimmed.startsWith('"')) {
|
|
518
|
+
// A quoted display name may contain anything, `<` included, so the
|
|
519
|
+
// address opens at the first `<` AFTER the closing quote.
|
|
520
|
+
const closingQuote = closingQuoteIndex(trimmed);
|
|
521
|
+
if (closingQuote === -1) return null;
|
|
522
|
+
open = trimmed.indexOf("<", closingQuote + 1);
|
|
523
|
+
if (open === -1) return null;
|
|
524
|
+
if (trimmed.slice(closingQuote + 1, open).trim() !== "") return null;
|
|
525
|
+
} else {
|
|
526
|
+
open = trimmed.indexOf("<");
|
|
527
|
+
if (open === -1) return null;
|
|
528
|
+
// An unquoted display name carries none of `<`, `>` or `@` — the same
|
|
529
|
+
// restriction the pattern expressed.
|
|
530
|
+
if (/[<>@]/.test(trimmed.slice(0, open))) return null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const address = trimmed.slice(open + 1, -1);
|
|
534
|
+
return address.length > 0 ? address : null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Index of the quote closing the one at position 0, or -1. */
|
|
538
|
+
function closingQuoteIndex(input: string): number {
|
|
539
|
+
for (let i = 1; i < input.length; i++) {
|
|
540
|
+
if (input[i] === "\\") {
|
|
541
|
+
i++;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
if (input[i] === '"') return i;
|
|
545
|
+
}
|
|
546
|
+
return -1;
|
|
547
|
+
}
|
|
499
548
|
|
|
500
549
|
/** A single DNS label: alphanumerics and inner hyphens, 1..63 chars. */
|
|
501
550
|
function isDnsLabel(label: string): boolean {
|
|
@@ -525,9 +574,15 @@ function isIpDomainLiteral(domain: string): boolean {
|
|
|
525
574
|
export function isEmail(value: string, options: EmailOptions = {}): boolean {
|
|
526
575
|
let candidate = value;
|
|
527
576
|
|
|
577
|
+
// Bound the input BEFORE any parsing. A caller may opt out of the 254-char
|
|
578
|
+
// address cap, but never out of a bound: an unbounded string reaching the
|
|
579
|
+
// parser is how a validator becomes an outage. RFC 5322 caps a whole line
|
|
580
|
+
// at 998 octets, so a display-name form has no business being longer.
|
|
581
|
+
if (value.length > MAX_EMAIL_INPUT) return false;
|
|
582
|
+
|
|
528
583
|
if (options.allow_display_name) {
|
|
529
|
-
const
|
|
530
|
-
if (
|
|
584
|
+
const address = displayNameAddress(candidate);
|
|
585
|
+
if (address !== null) candidate = address;
|
|
531
586
|
} else if (/[<>]/.test(candidate)) {
|
|
532
587
|
return false;
|
|
533
588
|
}
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,11 @@ export type {
|
|
|
21
21
|
} from "./formats.js";
|
|
22
22
|
export type { MessagesProviderContract } from "./MessagesProvider.js";
|
|
23
23
|
export { SimpleMessagesProvider } from "./MessagesProvider.js";
|
|
24
|
+
export {
|
|
25
|
+
assertNativeAvailable,
|
|
26
|
+
isNativeAvailable,
|
|
27
|
+
RuneNativeRequiredError,
|
|
28
|
+
} from "./native.js";
|
|
24
29
|
export type {
|
|
25
30
|
AsyncCompiledRule,
|
|
26
31
|
AsyncRuleValidator,
|
|
@@ -31,8 +36,10 @@ export type {
|
|
|
31
36
|
DatabaseRuleOptions,
|
|
32
37
|
FieldContext,
|
|
33
38
|
Infer,
|
|
39
|
+
RecordKeysCallback,
|
|
34
40
|
RuleChain,
|
|
35
41
|
RuleValidator,
|
|
42
|
+
UnionNoMatchCallback,
|
|
36
43
|
ValidateOptions,
|
|
37
44
|
ValidationError,
|
|
38
45
|
ValidationMessageParams,
|
package/src/native.ts
CHANGED
|
@@ -58,28 +58,43 @@ export function isNativeAvailable(): boolean {
|
|
|
58
58
|
return native !== undefined;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/** Why the engine could not be loaded, phrased for whoever has to fix it. */
|
|
62
|
+
function unavailableReason(): string {
|
|
63
|
+
const target = `${platform}-${arch}`;
|
|
64
|
+
if (loadError !== undefined) {
|
|
65
|
+
return `failed to load (${loadError instanceof Error ? loadError.message : String(loadError)})`;
|
|
66
|
+
}
|
|
67
|
+
return platformMap[target] !== undefined
|
|
68
|
+
? "binary not found"
|
|
69
|
+
: `no prebuilt binary for ${target}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Raised when a schema needs the Rust engine and it is not there. */
|
|
73
|
+
export class RuneNativeRequiredError extends Error {
|
|
74
|
+
readonly code = "RUNE_NAPI_REQUIRED" as const;
|
|
75
|
+
constructor() {
|
|
76
|
+
super(
|
|
77
|
+
`[RUNE_NAPI_REQUIRED] The Rust validation engine is required but not loaded — ${unavailableReason()}.\n` +
|
|
78
|
+
"Install the prebuilt binary for this platform, or build it with `pnpm build:napi`.",
|
|
79
|
+
);
|
|
80
|
+
this.name = "RuneNativeRequiredError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
62
83
|
|
|
63
84
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
85
|
+
* Refuse to validate without the native engine.
|
|
86
|
+
*
|
|
87
|
+
* There is a TypeScript validator, and it used to take over silently with a
|
|
88
|
+
* one-time warning. That made a schema's verdict depend on whether a prebuilt
|
|
89
|
+
* binary happened to load: two deployments of the same code could disagree on
|
|
90
|
+
* whether a payload is valid, and the one that fell back lost the reason to
|
|
91
|
+
* have Rust at all. A schema the engine can run is now run BY the engine or
|
|
92
|
+
* not at all.
|
|
93
|
+
*
|
|
94
|
+
* The TypeScript path stays for what the engine genuinely cannot do — custom
|
|
95
|
+
* rules, a translator, a messages provider — where it is the only
|
|
96
|
+
* implementation, not a second one.
|
|
69
97
|
*/
|
|
70
|
-
export function
|
|
71
|
-
if (
|
|
72
|
-
warnedFallback = true;
|
|
73
|
-
const target = `${platform}-${arch}`;
|
|
74
|
-
const reason =
|
|
75
|
-
loadError !== undefined
|
|
76
|
-
? `failed to load (${loadError instanceof Error ? loadError.message : String(loadError)})`
|
|
77
|
-
: platformMap[target] !== undefined
|
|
78
|
-
? "binary not found"
|
|
79
|
-
: `no prebuilt binary for ${target}`;
|
|
80
|
-
console.warn(
|
|
81
|
-
`[rune] native validation engine unavailable — ${reason}. Falling back to the ` +
|
|
82
|
-
"TypeScript validator, whose results can differ subtly from the Rust engine — " +
|
|
83
|
-
"validation is platform-dependent for this process.",
|
|
84
|
-
);
|
|
98
|
+
export function assertNativeAvailable(): void {
|
|
99
|
+
if (native === undefined) throw new RuneNativeRequiredError();
|
|
85
100
|
}
|