Skip to content
3 min readBy Judson Dunne

One kernel, three clients, and a script that enforces the boundary

RidgeSync's web planner, iPhone app and Apple Watch companion share one platform-agnostic package. The boundary only held because a build step fails when it is crossed.

RidgeSync runs on three surfaces: a web planner, an iPhone app and an Apple Watch companion. Everything that is not a pixel lives in one package, @trailplan/core: the trip schema, distance and elevation maths, food and packing calculations, routing calls, weather, history, offline manifests, entitlements.

The claim "we share the logic" is easy to make and usually false within two months. What made it true here is not the package. It is a script that fails the build.

How shared kernels actually die

They die one import at a time, and every individual step is reasonable. Someone needs the viewport width for a calculation, so window.innerWidth goes into a helper. Something needs to draw, so a Mapbox type gets imported for convenience. A date needs formatting in the user's locale, so Intl shows up with a browser assumption attached.

None of those break the web build, which is where the person making the change is working. They break React Native, where window is not that, and they break the Watch target hardest, because it consumes the smallest slice of the kernel and has the least tolerance for a transitive dependency on a map renderer.

The script

npm run check:core walks the kernel's sources and fails if anything platform-specific appears: browser globals, React Native imports, anything from Mapbox, anything that reaches for the DOM. It runs in CI and it runs before a release.

ts
const FORBIDDEN = [
  /\bwindow\b/, /\bdocument\b/, /\bnavigator\b/, /\blocalStorage\b/,
  /from\s+["']react-native/, /from\s+["']mapbox-gl/,
  /from\s+["']@rnmapbox/, /from\s+["']expo-/,
];

for (const file of coreSources()) {
  const src = read(file);
  for (const pattern of FORBIDDEN) {
    if (pattern.test(stripComments(src))) {
      fail(`${file}: ${pattern} is not allowed in @trailplan/core`);
    }
  }
}
The shape of it. A denylist, deliberately dumb and fast.

It is a regex denylist. It is not sound, it cannot see through a dynamic import, and a determined person can defeat it in one line. That is fine, because it is not a security boundary. It exists to catch the accidental version, which is the only version that actually happens, and it catches that on the commit rather than in a bug report from the Watch build three weeks later.

What the kernel gives the Watch

The Watch is the surface that proves the boundary was worth it. It does not need the planner, the map, or most of the trip document. It needs a small brief: the next stop, the distance to it, the elevation between here and there, and an estimate of when you will arrive.

That brief is a type in the kernel, and the native module speaks it. Because the shape is defined once, in a package with no platform assumptions, the phone and the watch cannot disagree about what a stop is, and adding a field to the brief is a change in one file rather than a negotiation between two codebases.

The honest limit

A platform-agnostic kernel is not free of platform thinking, it just moves it. Storage is the clearest case: the kernel owns the offline manifest, which says what must be local, and it deliberately does not own how anything gets stored. The web planner uses IndexedDB; the phone uses SQLite and MMKV. The kernel defines the contract and each client satisfies it.

Getting that split wrong in the other direction is the more common failure. A kernel that tries to abstract storage as well as describe it grows a plugin system, and then the plugin system is the product. Describing what must be true and letting each platform do it its own way is the version that stayed small.

What this is about

The projects