/** * @file a work-alike for enough of @endo/patterns: `M.string()`, `M.any()`, * and literal patterns (a string, number, boolean, or null matches only * itself, so `"cookie"` as a key shape admits exactly one key). Matchers * are the same data endo makes: tagged records, as from @endo/pass-style's * `makeTagged` (frozen here, where endo would harden). * * Structured clone (DO storage, Workers RPC) drops a tagged record's * symbol-keyed and non-enumerable properties, so patterns cross those * boundaries in endo's smallcaps encoding: see {@link toSmallcaps}. * @see https://github.com/endojs/endo/tree/master/packages/patterns * @see https://github.com/endojs/endo/blob/master/packages/marshal/src/encodeToSmallcaps.js */ const PASS_STYLE = Symbol.for("passStyle"); export type Tagged = Readonly<{ [Symbol.toStringTag]: string; payload: unknown }>; export type Pattern = Tagged | string | number | boolean | null; /** @endo/pass-style's `makeTagged` */ export function makeTagged(tag: string, payload: unknown): Tagged { return Object.freeze( Object.create(Object.prototype, { [PASS_STYLE]: { value: "tagged" }, [Symbol.toStringTag]: { value: tag }, payload: { value: payload, enumerable: true }, }), ) as Tagged; } const isTagged = (x: unknown): x is Tagged => typeof x === "object" && x !== null && (x as Record)[PASS_STYLE] === "tagged"; const StringShape = makeTagged("match:string", Object.freeze([])); const AnyShape = makeTagged("match:any", undefined); export const M = Object.freeze({ string: (): Tagged => StringShape, any: (): Tagged => AnyShape, }); export function matches(specimen: unknown, pattern: Pattern): boolean { if (typeof pattern === "object" && pattern !== null) { const tag = isTagged(pattern) ? pattern[Symbol.toStringTag] : undefined; switch (tag) { case "match:string": return typeof specimen === "string"; case "match:any": return true; default: throw new TypeError("not a pattern"); } } return Object.is(specimen, pattern); } /** the pattern as written in code, e.g. `M.string()` or `"cookie"` */ export function describe(pattern: Pattern): string { if (typeof pattern === "object" && pattern !== null) { return `M.${pattern[Symbol.toStringTag].replace(/^match:/, "")}()`; } return JSON.stringify(pattern); } /** * throw unless `specimen` matches `pattern`. Unlike endo's, the message * names the pattern and `label` but never the specimen: specimens here * can be credentials, and messages cross RPC and land in logs. */ export function mustMatch(specimen: unknown, pattern: Pattern, label: string): void { if (!matches(specimen, pattern)) { throw new TypeError(`${label}: must match ${describe(pattern)}`); } } /** a smallcaps-encoded passable, as JSON-able data */ export type Smallcaps = null | boolean | number | string | Smallcaps[] | { "#tag": string; payload: Smallcaps }; /** smallcaps quotes strings that start with one of these with `!` */ const startsSpecial = (s: string) => /^[!-\-]/.test(s); /** * the subset of @endo/marshal's smallcaps encoding that patterns here use: * primitives, arrays, and tagged records (not a whole CapData: no `#` * body prefix, no slots) */ export function toSmallcaps(passable: unknown): Smallcaps { if (passable === undefined) return "#undefined"; if (passable === null || typeof passable === "boolean") return passable; if (typeof passable === "number") { if (Number.isNaN(passable)) return "#NaN"; if (passable === Infinity) return "#Infinity"; if (passable === -Infinity) return "#-Infinity"; return Object.is(passable, -0) ? 0 : passable; } if (typeof passable === "string") return startsSpecial(passable) ? `!${passable}` : passable; if (Array.isArray(passable)) return passable.map(toSmallcaps); if (isTagged(passable)) { return { "#tag": toSmallcaps(passable[Symbol.toStringTag]) as string, payload: toSmallcaps(passable.payload) }; } throw new TypeError("toSmallcaps: unsupported passable"); } /** the inverse of {@link toSmallcaps} */ export function fromSmallcaps(encoding: Smallcaps): unknown { if (encoding === null || typeof encoding === "boolean" || typeof encoding === "number") return encoding; if (typeof encoding === "string") { if (encoding.startsWith("!")) return encoding.slice(1); switch (encoding) { case "#undefined": return undefined; case "#NaN": return NaN; case "#Infinity": return Infinity; case "#-Infinity": return -Infinity; } if (startsSpecial(encoding)) throw new TypeError("fromSmallcaps: unsupported special string"); return encoding; } if (Array.isArray(encoding)) return Object.freeze(encoding.map(fromSmallcaps)); if (typeof encoding === "object" && "#tag" in encoding) { const { "#tag": tag, payload, ...rest } = encoding; if (Object.keys(rest).length > 0) throw new TypeError("fromSmallcaps: extra properties on a tagged record"); return makeTagged(fromSmallcaps(tag) as string, fromSmallcaps(payload)); } throw new TypeError("fromSmallcaps: unsupported encoding"); }