ReviewOS

craft-native/craft

exports map has only ".": ./mobile, ./android and ./ios are unreachable, and dist/api/mobile.js is never emitted

#33
Closed glennmichael123 opened this 11 days ago ·
11 days ago

Summary

craft-native ships the mobile, Android and iOS implementations but no way to import them. The exports map declares only ".", so every subpath specifier fails to resolve, and the one entry that does resolve is Node-only and cannot be bundled for a browser.

The practical effect downstream is that @stacksjs/mobile is unusable when installed from npm. Filing here because no in-repo change on our side can fix it: the implementation the subpath would point at is not emitted as a separate artifact at all.

All of the below is measured against craft-native 0.0.55, which npm view craft-native version reports as the latest published release.

What ships today

dist/index.js            338 KB   the ESM root entry
dist/index.cjs           345 KB   the CJS root entry
dist/api/mobile.d.ts      17 KB   types only
dist/api/mobile.js        MISSING
dist/android/src/index.js  11 KB  real JS
dist/ios/src/index.js     7.4 KB  real JS

And the full exports map:

{
  ".": {
    "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
    "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
  }
}

So there are three separate problems:

  1. dist/api/mobile.js is never emitted. Only its .d.ts ships. The implementation is inlined into the Node-only root bundle, so no export entry could point at it even if one were added.
  2. ./android and ./ios have no export entry. Unlike mobile, these two already ship as real, standalone JS. They are unreachable purely because exports does not list them.
  3. The root entry is not browser-safe, so it is not a viable fallback for any of them.

The root entry cannot substitute

await Bun.build({ entrypoints: ['craft-native/dist/index.js'], target: 'browser' })
ResolveMessage: Browser build cannot import Node.js builtin: "child_process".
To use Node.js builtins, set target to 'node' or 'bun'

The same test passes for both of the standalone entries:

entrybrowser build
dist/index.jsfails on child_process
dist/android/src/index.jsOK
dist/ios/src/index.jsOK

That last row is the useful part: two of the three subpaths are already browser-clean and need nothing but an exports entry.

Who is asking for these

  • @stacksjs/mobile calls require('craft-native/mobile')
  • @stacksjs/actions resolves craft-native/android and craft-native/ios for buddy build:android / build:ios

Because the specifier is unresolvable, our bundler emits a throwing stub and the failure surfaces as a runtime TypeError in whichever component touched a native surface first, rather than as an install-time or build-time error.

Worth flagging as a related symptom: our peer range is currently craft-native: ">=0.0.65", and our error message tells people to install >=0.0.65, the first release exporting ./mobile. That release does not exist, so the advice is unfollowable. That is ours to correct, and we will once we know the version this lands in.

Suggested fix

  1. Emit dist/api/mobile.js as a standalone entry, built browser-clean rather than inlined into the Node bundle.
  2. Add the export entries:
"./mobile":  { "types": "./dist/api/mobile.d.ts",      "default": "./dist/api/mobile.js" },
"./android": { "types": "./dist/android/src/index.d.ts", "default": "./dist/android/src/index.js" },
"./ios":     { "types": "./dist/ios/src/index.d.ts",     "default": "./dist/ios/src/index.js" }

One detail that would otherwise regress this quietly: the . entry already splits import and require, and our call site deliberately uses require('craft-native/mobile'). If ./mobile gets a require condition pointing at a CJS artifact built for Node, a bundler resolving that condition lands back on Node builtins and the problem returns through the sanctioned path. It needs to be browser-clean under both conditions, or expose only a single browser-safe artifact.

Acceptance test

const r = await Bun.build({
  entrypoints: [Bun.resolveSync('craft-native/mobile', process.cwd())],
  target: 'browser',
})
if (!r.success) throw new Error('mobile entry is not browser-clean')

Repeating it for craft-native/android and craft-native/ios would keep all three honest.

Context

Downstream tracking issue: stacksjs/stacks#2322.

Happy to open a PR for the exports additions if that is useful, though the dist/api/mobile.js emit is a build-config change I would rather leave to you.

11 days ago

Audited against the current tree. All three claims are already fixed — this issue was measured against published 0.0.55, and the repo is at 0.0.67.

claimstatus
dist/api/mobile.js never emitted, so no export entry could point at itfixed — ./mobile points at ./dist/mobile.js, which build.ts emits with target: 'browser'
./android and ./ios have no export entryfixed — both declared, both resolving to real emitted JS
the root entry is not browser-safestill true, and now correct by design — craft-native/mobile is the browser entry, and build.ts hard-fails if it ever stops bundling for target: 'browser'

Verified by packing the tarball and installing it into a clean consumer: craft-native, craft-native/mobile, craft-native/ios and craft-native/android all resolve under Node's ESM resolver, and every one of the 11 files the exports map names exists after a build — 0 missing.

What the audit found underneath

Three real defects the issue did not claim, all now fixed in #42:

  1. Types resolved to nothing under moduleResolution: node16/nodenext. dist/index.d.ts shipped 15 extensionless re-exports and dist/mobile.d.ts one, so a consumer got TS2834 followed by TS2305 on symbols the package genuinely exports. Invisible because this repo builds itself with bundler, where extensionless is legal — tsc enforces it only consumer-side, and nothing tested a consumer. ts-maps had it too.
  2. No node10 fallback for the three subpaths — and packages/react, packages/vue and packages/svelte in this repo still set moduleResolution: node, so that is not hypothetical. typesVersions added.
  3. ./ios and ./android bundled green for the browser and then threw Cannot destructure property '$' of 'globalThis.Bun' at module evaluation. Now "browser": null, so the failure arrives at bundle time instead.

All four resolution modes now typecheck: nodenext, node16, bundler, node. Guarded in verify-packages.ts, which fails on any shipped declaration carrying an extensionless relative specifier.

Why this is not closed yet

None of it has reached anyone. npm latest for craft-native is still 0.0.55, published 2026-07-30, while tags v0.0.56–v0.0.67 all exist — so every symptom described here is still exactly what an npm consumer sees.

The reason is #11: release.yml's npm job is needs: [pantry], the darwin leg fails deterministically on the missing APPLE_* secrets, and so the npm publish is skipped on every tag. This closes when a release actually ships.

11 days ago

Fixed and shipped. Verified against the published craft-native@0.0.76 rather than the manifest — a fresh consumer install imports all four entry points:

craft-native           ok — 217 exports
craft-native/mobile    ok — 21 exports
craft-native/ios       ok — 17 exports
craft-native/android   ok — 7 exports

Every export is defined at runtime, not just resolvable.

On the second half of the title: dist/api/mobile.js is still not emitted, and does not need to be. api/mobile appears in the tarball only as a bundler comment (// src/api/mobile.ts) — the code is inlined into dist/mobile.js and dist/index.js. dist/api/ holds 20 .d.ts files and no .js, which is what typesVersions wants.

Sign in to comment on this issue.