npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

blazingly-aasa

v0.1.5

Published

Apple Associated Domains (apple-app-site-association) semantics: parse, validate, match, explain, and diff — compiled to WebAssembly

Readme

blazingly-aasa

Apple Associated Domains semantics for Rust and WebAssembly. Parse, validate, match, explain, and diff apple-app-site-association policy.

CI WebAssembly crates.io docs.rs npm AASA conformance 139/140 oracle MSRV 1.78 license MIT


An apple-app-site-association file is the JSON document a website serves at /.well-known/apple-app-site-association to say which apps may open which of its URLs — universal links, App Clips, shared web credentials, Handoff. It is a small file with surprisingly sharp semantics: rules are ordered and the first match wins, exclude stops the scan rather than falling through, and three levels of defaults override each other.

Most tooling reduces all of that to a green checkmark. When a universal link silently stops working, a checkmark tells you nothing.

This crate gives you the answer and the reason:

NO_MATCH

application: ABCDE12345.com.example.app
domain:      example.com
url:         https://example.com/help/1?articleNumber=481

reason:
  the entries that apply to ABCDE12345.com.example.app have no rule matching this URL

closest failure:
  detail #0, rule #3
  [ok  ] path
         url:     /help/1
         pattern: /help/*
         wildcard match
  [FAIL] query[articleNumber]
         url:     481
         pattern: ????
         pattern did not match

An independent, cross-platform AASA matcher differential-tested against Apple's own swcutil: 139 of 140 conformance cases oracle-verified, with the raw runs committed in conformance/oracle so the conclusions can be audited without a Mac. That check found four places where this crate was wrong, including one it had been confident enough about to ship as a lint — docs/parity.md has each of them.

What it does

  • Parses every shape in the wild — modern components, legacy paths with NOT exclusions, and the oldest details-as-a-dictionary form — leniently, so one broken entry never hides the rest of the file.
  • Validates with 27 stable, machine-readable AASA### codes: unreachable rules, catch-alls that open a whole domain by accident, recursive substitution variables, a single non-string query predicate that silently voids every constraint beside it, mixed legacy and modern formats.
  • Matches a URL for an app, with full trace: which detail entry, which rule index, what the effective caseSensitive and percentEncoded were, and exactly which component failed.
  • Compares two files by effective policy, not bytes. Hoisting caseSensitive into defaults reports no change. The comparison is conservative: equivalent means identical decisions for every URL, while a reported difference means potentially different.
  • Answers in both directions: does this app get this URL, and which apps does a URL reach.
  • Reads CMS-signed files from the iOS 9 era, which every JavaScript tool rejects as invalid JSON — extracting the payload, and saying plainly that the signature was not verified.
  • Runs everywhere: Rust, and a WebAssembly package for browsers, Node, and Bun.

What it does not do

It never touches the network, never opens an .ipa, and never claims to know what a device will do. A match means this document considers this URL eligible for this app — not that the link will open the app, which also depends on install state, entitlements, and what Apple's CDN is currently serving. Those belong to the tools built on this crate; see docs/aasadiff-integration.md.

Rust

[dependencies]
blazingly-aasa = "0.1"
use blazingly_aasa::{CompiledAasa, MatchDecision};

let aasa = CompiledAasa::parse(document)?;
let app = "ABCDE12345.com.example.app";

// The fast path: a decision, nothing else.
let decision = aasa.decide("example.com", app, "https://example.com/help/1?articleNumber=4815")?;
assert_eq!(decision, MatchDecision::Match);

// Costs more, answers "why".
let miss = aasa.match_url("example.com", app, "https://example.com/help/1?articleNumber=481")?;
println!("{miss}");

Against a document whose first rule excludes /help/website/* and whose second requires a four-character articleNumber, that prints — this is the real output of examples/matching.rs, not a paraphrase:

MATCH    https://example.com/help/1?articleNumber=4815
NO_MATCH https://example.com/help/1?articleNumber=481
BLOCK    https://example.com/help/website/faq
NO_MATCH https://example.com/store

NO_MATCH

application: ABCDE12345.com.example.app
domain:      example.com
url:         https://example.com/help/1?articleNumber=481

reason:
  the entries that apply to ABCDE12345.com.example.app have no rule matching this URL

closest failure:
  detail #0, rule #1
  [ok  ] path
         url:     /help/1
         pattern: /help/*
         wildcard match
  [FAIL] query[articleNumber]
         url:     481
         pattern: ????
         pattern did not match


ABCDE12345.com.example.app: MATCH

A near miss is an answer, not an error: the trace names the rule that came closest and the one component that failed, with the pattern and the input side by side.

Linting, with stable codes you can gate CI on (examples/validate.rs):

error [AASA150] applinks.details[0].components[0].?.flag: query predicate is a boolean, but Apple documents only string patterns here
  help: Apple ignores the entire query dictionary when any predicate is not a string, so every query constraint in this rule stops applying and the rule matches more URLs, not fewer. Replace every predicate with a string pattern.
error [AASA110] applinks.details[1]: this entry names no application identifier
  help: add `appID` or `appIDs`
warning [AASA180] applinks.details[0].components[0]: this rule constrains no URL component, so it matches every URL
  help: it opens the whole domain for this app; add `/`, `?`, or `#` if that was not intended
warning [AASA190] applinks.details[0].components[1]: rule #0 already matches every URL, so this rule never runs
  help: the first matching rule wins; move this rule above the catch-all
warning [AASA180] applinks.details[0].components[2]: this rule constrains no URL component, so it matches every URL (comment: everything else)
  help: it opens the whole domain for this app; the comment suggests that is intended

errors: 2  warnings: 3
has_errors: true
AASA150 present: a query dictionary in this file is inert

Note the chain: the non-string flag predicate makes Apple discard the whole ? dictionary, which leaves rule #0 constraining nothing — so the catch-all warning fires on a rule the author thought was narrow, and the rule after it becomes unreachable. One mistake, three diagnostics.

Comparing what you serve against what Apple's CDN serves, by effective policy rather than bytes (examples/diff.rs):

origin vs reformatted
  equivalent:          true
  structurally_equal:  false

origin vs stale CDN copy
  equivalent: false
  RULE_CHANGED    ABCDE12345.com.example.app #0
  before: / = /help/*, caseSensitive=false, percentEncoded=true
  after:  / = /help/*, caseSensitive=true, percentEncoded=true

https://example.com/HELP/1
  origin: MATCH
  stale:  NO_MATCH

Every method

Parsing. CompiledAasa::parse(bytes) and parse_with(bytes, &ParseOptions), which sets the size limit and whether unknown keys are reported. Both accept a signed (CMS/PKCS#7) file and extract the payload. document() returns the parsed AasaDocument behind it.

Deciding.

| Method | Answers | | --- | --- | | decide(domain, app_id, url) | Match / Exclude / NoMatch, allocation-free | | decide_parts(domain, app_id, &UrlParts) | the same, when the URL is already split | | match_url(domain, app_id, url) | the decision plus the full trace | | match_parts(domain, app_id, &UrlParts) | the same, from split parts | | apps_for_url(domain, url) | every app the URL reaches, with each decision | | apps_for_url_parts(domain, &UrlParts) | the same, from split parts |

Asking about the document.

| Method | Answers | | --- | --- | | has_applinks() | whether an applinks section exists at all | | applink_apps() | every app identifier under applinks | | has_applink_app(app_id) | whether one app is listed | | has_webcredential_app(app_id) | shared web credentials | | has_appclip(app_id) | App Clips | | has_activitycontinuation_app(app_id) | Handoff | | services_for_app(app_id) | every service one app is enrolled in | | services_for_bundle(team_id, bundle_id) | the same, addressed by team and bundle | | app_ids_for_bundle(bundle_id) | every team that ships this bundle id | | apps_for_service(Service) | the inverse: who is enrolled in one service | | effective_rules_for(app_id) | the rules after the defaults hierarchy is applied | | substitution_variables() | the $(...) tables the document defines |

Comparing. semantic_diff(&other) returns an AasaDiff with is_equivalent() and changes(); semantic_equal(&other) is the boolean; structural_equal(&other) compares the normalised documents instead; to_normalized_json() is that normal form.

equivalent == true guarantees the same decision for every URL. false means they may differ — it does not prove they do, and no witness URL is produced. The docs/roadmap.md entry on behavioural diffing is about closing that gap.

Validating. validate() returns a ValidationReport: diagnostics(), errors(), warnings(), infos(), has_errors(), is_empty(), and contains(DiagnosticCode) for gating CI on one specific finding. Every code is listed in docs/diagnostics.md; DiagnosticCode::all() enumerates them at runtime.

Free functions. validate(bytes), match_url(bytes, domain, app_id, url), and diff(left, right) do the whole job in one call when you will not reuse the document. split_app_id, trim_path, strip_leading_slash, and percent_decode are the URL helpers the matcher itself uses. WildcardPattern exposes the glob engine on its own — note that it is only the glob engine: WildcardPattern::compile("/buy/*", true)?.matches("/buy") is false, while decide on the same pattern answers Match, because a rule's / component also matches the parent path. Reach for it to test a pattern, not to answer a question about a URL.

Three runnable examples live in examples/. CI diffs their output against examples/expected/, so the blocks above cannot drift from what the code actually prints.

JavaScript

npm install blazingly-aasa
import { Aasa } from "blazingly-aasa";

const response = await fetch("https://example.com/.well-known/apple-app-site-association");
const aasa = Aasa.compile(new Uint8Array(await response.arrayBuffer()), "example.com");

try {
  for (const d of aasa.validate()) {
    console.log(`${d.severity} ${d.code} ${d.path}: ${d.message}`);
  }
  console.log(aasa.decide(appId, url));   // "match" | "exclude" | "no_match"
  console.log(aasa.explain(appId, url));  // the same decision, in words
} finally {
  aasa.free();
}

Aasa holds WebAssembly memory. Call free() when you are done with it — a try/finally is the honest way to do that.

Every method

| Method | Answers | | --- | --- | | Aasa.compile(bytes, domain, maxBytes?) | a handle to reuse; maxBytes caps the payload | | .domain | the domain it was compiled for | | validate() | the diagnostics, as objects | | hasErrors() | whether any diagnostic is an error | | decide(appId, url) | "match" / "exclude" / "no_match" | | decideMany(appId, urls[]) | one crossing for an array of URLs | | decideManyCodes(appId, urls[]) | the same as bytes: 0 no match, 1 match, 2 exclude, 3 bad URL | | decideLines(appId, newlineSeparated) | the same again, without building a JS array | | match(appId, url) | decision plus trace, as an object | | matchJson(appId, url) | the same, as a JSON string | | explain(appId, url) | the trace as human-readable text | | appsForUrl(url) | every app the URL reaches | | applinkApps() | every app under applinks | | servicesForApp(appId) | which services one app is enrolled in | | servicesForBundle(teamId, bundleId) | the same, by team and bundle | | appIdsForBundle(bundleId) | every team shipping this bundle id | | normalizedJson() | the normal form used for comparison | | semanticDiff(other) | the changes between two documents | | semanticEqual(other) | whether they decide every URL alike |

One-shot functions, when there is nothing to reuse: validateAasa(bytes), matchAasa(bytes, domain, appId, url), diffAasa(left, right), matchPattern(pattern, input, caseSensitive), splitAppId(appId), isoTableSource(). matchPattern is the glob engine alone — it answers false for ("/buy/*", "/buy"), where decide answers "match" — so use it to test a pattern, not to decide a URL. setPanicHook() routes Rust panics to console.error while debugging.

Works in browsers, Node, and Bun. Packaging details in docs/wasm.md.

How this compares

There are four AASA tools with real usage. docs/competitors.md reads each one's source and maps what it covers. The short version: they are validators, this is an engine.

yurl, Universal-Link-Validator, and @linkforty/aasa-core fetch the file and check how it is hosted — genuinely valuable, and deliberately not this crate's job. None of them evaluates a URL against the rules at all.

st-tech/universal-links-test does, and it is well built: rule ordering, exclude, wildcards, and the defaults hierarchy are all correct. So it can be scored against the same corpus this crate runs:

| Feature | universal-links-test | blazingly-aasa | | --- | --- | --- | | rule order, exclude, defaults, appIDs | 22/22 | 22/22 | | wildcards | 7/8 | 8/8 | | query | 22/38 | 38/38 | | percent encoding | 8/15 | 15/15 | | path slashes | 13/25 | 25/25 | | legacy paths, legacy details | 1/4 | 4/4 | | substitution variables | 10/20 | 20/20 | | total | 88/137 | 137/137 |

That substitution row is the reason this crate exists, and it needs reading carefully. Exactly ten of those twenty cases expect no_match; it passes all ten of those and none of the other ten — because no surveyed tool expands $(...) at all. They declare substitutionVariables in their types and ignore it when matching. Its score there is not "half right", it is zero right with half the cases passing by accident.

That is the dangerous failure mode: a file using $(lang) does not error, it silently matches nothing, and the check stays green.

The AASA conformance corpus

conformance/ is a test suite for the format, not for this crate.

140 matching cases and 13 validation cases, each tagged with the feature it covers, a link to the Apple page that documents it, and how the expectation was established:

| | | | --- | --- | | oracle | checked against Apple's swcutil, with the raw run committed — 139 of 140 | | documented | Apple states it and a test asserts it | | decided | Apple does not state it and the oracle cannot answer it — 1 case, this crate's own convention that an empty domain skips the host check |

It is deliberately implementation-neutral, and scoring your own matcher takes one command:

node conformance/run.mjs --exec "./your-matcher"

Your program reads one JSON case per line and writes one decision per line — nine lines of work in any language. conformance/PROTOCOL.md is the contract; conformance/adapters/ holds two reference implementations, one binding a library in JavaScript and one shelling out to a command line from Python, both scoring 140/140 and both run in CI so the contract cannot rot.

The report separates real passes from accidental ones:

feature              score   of which trivial
ok   rule-order      11/11   4 expect no_match
FAIL substitutions   10/20   10 expect no_match

Ten of the twenty substitution cases expect no_match, so an implementation that silently matches nothing passes all ten by accident. 10/20 there is zero right, not half — and a comparison that hides this flatters the loser. That column is why the tables in docs/competitors.md can be read at face value.

If a case is wrong, that is a bug worth an issue. It is checked against Apple's tool, not against this crate's opinion, and conformance/oracle/ has the raw runs so the conclusions can be audited without a Mac.

Performance

Apple M4, macOS 27.0, rustc 1.96.1, criterion. Every figure is a ratio against a baseline measured in the same run, because absolute numbers on a shared machine are not comparable across runs — in one pair of runs here the untouched regex baseline itself moved by 2.8x. Reproduce with cargo bench and node bindings/wasm/bench/bench.mjs.

The baseline is what a competent engineer would actually build: serde_json for parsing plus the regex crate for wildcards, which is how nearly every AASA checker in the wild works. Both sides use the same URL splitter. The corpus contains no $(...), because the baseline does not implement substitution variables and would otherwise be credited for skipping work.

Matching one pattern

| Pattern | vs regex | | --- | --- | | /help/website/faq (literal) | 6.4x faster | | /buy/* (prefix) | 22x faster | | */checkout (suffix) | 18x faster | | /id/???? | 1.7x slower | | /id/$(digit)$(digit)$(digit)$(digit) | 1.7x slower | | /a/*/b/?*/c | 2.3x slower | | *a*a…*b on 512 as (adversarial) | 1.7x slower |

The first three shapes cover almost every pattern in a real association file and take allocation-free string tests. On genuinely general patterns a mature DFA beats a glob matcher by under 2.5x — the honest cost of not shipping a regex engine — and the adversarial row shows neither engine degrades catastrophically.

Compiling

| | vs regex | | --- | --- | | one pattern (literal / prefix / ???? / mixed) | 25x – 295x faster | | a 0.4 KiB document | 22x faster | | a 5 KiB document, 128 rules | 24x faster | | a 38 KiB document, 1024 rules | 28x faster |

Read the document rows next to this one, because most of that gap is the regex compiler rather than the JSON parser:

| JSON parse only | blazingly-json vs serde_json | | --- | --- | | 0.4 KiB / 5 KiB / 38 KiB | 1.13x / 1.18x / 1.33x faster |

Matching a real document

This is where the crate is slower than the baseline, and the reason is worth stating plainly.

| | vs serde_json + regex | | --- | --- | | 8 URLs against 8 apps x 16 rules | 2.4x slower | | a miss scanned across 1 / 8 / 32 app entries | 2.1x / 1.9x / 1.6x slower |

Before this crate was checked against Apple's swcutil it was at parity here — 0.99x, 1.00x, 1.00x on those same rows. It got slower by getting correct. swcutil settled four behaviours the baseline does not implement at all:

  • a pattern ending in /* also matches the parent path, so /buy/* needs two comparisons;
  • every occurrence of a repeated query name must match, so the predicate loop cannot stop at the first hit;
  • a missing query item counts as present with an empty value, so absence is a comparison rather than an immediate reject;
  • the leading slash of a pattern is optional.

The baseline is faster partly because it is wrong. A comparison that omitted that would be measuring less work, not better work.

Two things did come back from the first, naive version of those rules: trimming the path once per match instead of once per rule, and deciding at compile time that a /-rooted pattern can never match a path without one. That took the regression from 5.9x down to the rows above — the cost settles around 2x, and the more app entries a miss has to scan, the smaller it gets, because the per-rule work the baseline skips is not what dominates at that point.

| | | | --- | --- | | compiled.decide(...) vs reparsing per call | 924x faster | | decide vs match_url with a full trace | trace costs ~7x |

Parse once, match many. The trace is why decide and match_url are separate calls rather than one function with a flag.

WebAssembly against pure JavaScript

Against a JSON.parse + RegExp implementation — the JavaScript equivalent of the Rust baseline:

| | WebAssembly vs pure JS | | --- | --- | | compile 0.4 KiB / 5 KiB / 38 KiB | 0.71x / 0.66x / 1.19x | | match, decideLines batch | 0.82x – 1.20x |

Roughly a wash, and sometimes worse. Moving a string across the boundary costs more than matching it, and that cost is per string, so it does not amortise over a batch. The earlier ~2x compile advantage narrowed when compilation took on the parent-path form.

The reason to use the WebAssembly build is not speed. It is that the semantics are the ones verified against swcutil, with the same diagnostics, traces, and diff — rather than a second implementation that will drift, which is exactly what the pure-JS tools in the comparison above turned out to be.

Payload: 358 KB raw, 144 KB gzip, 115 KB brotli.

Correctness

Apple's reference pages leave real questions open. Rather than guessing and presenting the guess as fact, every behaviour is classified:

  • oracle — checked against Apple's swcutil, with the run committed.
  • documented — Apple states it and a test asserts it, but no oracle run covers it.
  • decided — Apple does not state it and the oracle cannot speak to it.

docs/parity.md is that table, feature by feature. 139 of the 140 matching cases are now verified against Apple's own swcutil, with the raw runs committed in conformance/oracle so the conclusions are auditable without a Mac. The one exception is this crate's own API convention that an empty domain skips the host check, which swcutil has no way to express.

$(region) does not match UK. Apple's prose gives "CA, UK, and US" as example regions, but UK is not an ISO 3166-1 alpha-2 code and does not appear in Locale.isoRegionCodes — the United Kingdom is GB. The $(region) and $(lang) tables are generated from Foundation by scripts/generate_iso_tables.swift rather than transcribed, so the list Apple points at wins over the prose. swcutil agrees: it does not match UK either.

And the discipline caught this crate being wrong four times. The first differential run against swcutil agreed on 68 of 73 cases. The other four were all this crate's fault, including one it had been confident enough about to ship as a lint: AASA191 warned that a path pattern without a leading slash could never match, since URL paths start with /. Apple matches abc against /abc. The lint was removed, its number retired, and the documentation example it contradicted now passes as a test. The others were a missing query item, a repeated query name, and a non-string predicate — see docs/parity.md for each.

The test suite is 114 tests across Apple's documented examples, parsing, validation, matching, percent-encoding, and semantic diff — plus property tests that check the pattern matcher against a deliberately naive exponential reference implementation, that parsing arbitrary bytes never panics, and that the fast decision path never disagrees with the tracing one.

How the pattern engine works

Apple's wildcard language is * (zero or more), ? (exactly one), and therefore ?* (one or more), plus $(name) substitution references. The obvious implementation translates it to a regular expression. This crate does not, for three reasons: you would have to prove the translation equivalent, ship a regex engine to every WebAssembly consumer, and pay regex compilation for every rule in the file.

Instead, patterns compile to one of three engines, chosen at compile time:

| Shape | Engine | | --- | --- | | /help/website/faq, /buy/*, */checkout, *sale* | direct string test, no allocation | | anything from literals, ?, *, and single-character classes | greedy glob, no heap | | contains $(region), $(lang), or a custom variable | bitset NFA over reachable positions |

None backtracks exponentially — the classic *a*a*a…*b blow-up is bounded by O(positions x tokens). Input that is entirely ASCII, which URL components almost always are, is matched directly against the string's bytes.

You can use the matcher on its own:

use blazingly_aasa::WildcardPattern;

let pattern = WildcardPattern::compile("/id/$(digit)$(digit)", true)?;
assert!(pattern.matches("/id/42"));
assert!(!pattern.matches("/id/4x"));

Dependencies

blazingly-json and serde. That is the whole runtime dependency list — no HTTP client, no regex engine, no async runtime, no URL crate. URLs are split by a small RFC 3986 splitter that preserves each component exactly as written, because matching compares against the URL as the system saw it; normalising first would change what the patterns see.

serde_json and regex appear only as dev-dependencies, as benchmark baselines.

Using it as a tool

This crate is an engine, not a program. If you want the program:

blazingly-aasa-mcp — an MCP server and CLI built on it. It fetches a domain's file, matches a URL, explains the decision, and compares what a site serves against what Apple's CDN is handing to devices:

cargo install --git https://github.com/sergii-ziborov/blazingly-aasa-mcp
blazingly-aasa check example.com "https://example.com/buy/42" --app ABCDE12345.com.example.app

The split is deliberate: everything network-shaped lives there, and this crate keeps two dependencies and compiles to WebAssembly. See docs/aasadiff-integration.md for where the line sits.

Documentation

| | | | --- | --- | | docs/findings.md | what it actually caught — in production files, and in its own code | | docs/competitors.md | what the existing tools cover, measured against the corpus | | docs/roadmap.md | why there is no hand-written JS port, and no MCP server yet | | docs/semantics.md | what is implemented and where each rule comes from | | docs/parity.md | feature-by-feature: documented by Apple, or decided here | | docs/diagnostics.md | every AASA### code and a suggested CI policy | | docs/wasm.md | the WebAssembly design, its limits, and the API | | docs/aasadiff-integration.md | where this crate ends and your tool begins | | AGENTS.md | guardrails for contributors |

Development

cargo test --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo +1.78 check -p blazingly-aasa --lib

./bindings/wasm/build.sh
node bindings/wasm/tests/node.test.mjs
node bindings/wasm/tests/conformance.mjs
bun  bindings/wasm/tests/conformance.mjs

Benchmarks:

cargo bench --bench pattern_engine
cargo bench --bench compile
cargo bench --bench matching
node bindings/wasm/bench/bench.mjs

License

MIT. See LICENSE.