@sigx/lynx-core
v0.35.0
Published
Core native bridge for sigx-lynx — low-level access to NativeModules
Readme
@sigx/lynx-core
Low-level internals. Most app code should import from
@sigx/lynxor the specific module package (@sigx/lynx-camera,@sigx/lynx-haptics, …). This package's API is intended for authors of new native modules.
Low-level native-module bridge for sigx-lynx. Every @sigx/lynx-* native module package (-camera, -haptics, -storage, -network, …) depends on this for its iOS/Android plumbing.
📚 Documentation
Full guides, API reference and live examples → https://sigx.dev/lynx/modules/core/overview/
API
import {
getModule,
callSync,
callAsync,
isModuleAvailable,
guardModule,
} from '@sigx/lynx-core';getModule(name)— return theNativeModules[name]proxy that the Lynx runtime injects. It throws if the module isn't linked (aSigxErrorwithcode: 'module_unavailable', naming the package to install); it never returnsundefined. UseisModuleAvailable(name)to feature-detect without throwing.callSync(name, method, ...args)— invoke a bridge method that returns synchronously.callAsync(name, method, ...args)— invoke a bridge method that returns aPromise.isModuleAvailable(name)— feature-detect a module without throwing.guardModule(name)— throw a descriptive error if the module isn't linked (use at module-package entry points).
The module contract
Three helpers every @sigx/lynx-* package is expected to use rather than reimplement. They are the executable half of CONVENTIONS.md C4, C7 and C10.
import { subscribeNative, unwrapNative, SigxError } from '@sigx/lynx-core';subscribeNative(channel, cb, options?)— subscribe to a native event channel; returns an unsubscribe function (never a{ remove() }object), and calling it twice is a no-op. Handles the four things every hand-rolled copy had to get right independently: fetching the emitter throughlynx.getJSModule, payloads that arrive as a JSON string rather than an object, a listener that throws (contained and reported through the logger, notconsole), and off-device — where it is a silent no-op, so a package can subscribe unconditionally without branching on availability. Passoptions.validateto drop payloads that don't match the expected shape, andoptions.namespaceto tag the diagnostic with your package.const off = subscribeNative<PushEvent>('__sigxPush', onPush, { validate: (raw): raw is PushEvent => typeof (raw as PushEvent)?.id === 'string', namespace: 'lynx-notifications', });isNativeEventsAvailable()— whether the runtime'sGlobalEventEmitteris reachable right now. Most packages don't need it:subscribeNativeis a safe no-op off-device, so just subscribe. It's for the lazy-latch pattern — a module that wires on its first API call and must retry until the emitter appears, because that call can race runtime init. The disposer can't answer that question (the off-device no-op is indistinguishable from a real one), and re-derivinglynx.getJSModuleto answer it is what this contract exists to prevent.if (!wired && isNativeEventsAvailable()) { subscribeNative(CHANNEL, onEvent, { namespace: 'lynx-http' }); wired = true; // latch only on success, so a racing first call retries }unwrapNative(pkg, action, raw)—callAsynconly rejects when the synchronous call throws; native failures come back on the resolved callback as{ error }. This unwraps them once, throwing[@sigx/lynx-<pkg>] <action> failed: <cause>.unwrapNativeVoidis the same for methods with no success payload.const raw = await callAsync<{ uri?: string; error?: string }>('Camera', 'takePicture', opts); const { uri } = unwrapNative('lynx-camera', 'takePicture', raw);SigxError— base error carrying a stablecodeand the raisingpackage, for anything a caller might branch on. Narrow withisSigxError(e); branch one.code, never on the message.Core itself raises two codes, and between them they cover the failures every module package inherits without writing any error of its own:
|
code| Raised by | Means | | --- | --- | --- | |'module_unavailable'|getModule/callSync/callAsync/guardModule| The native module isn't in this build. Not recoverable at runtime — feature-detect withisAvailable()instead of catching. | |'native_error'|unwrapNative/unwrapNativeVoid| The platform reported a failure; the raw native payload is oncause. |
App environment
env is the typed env block of the app's signalx.config.ts, with the active
--variant's overrides merged in. It is baked into the bundle at build time
(OTA bundles included) and frozen. It is {} when the app declares no env.
import { env } from '@sigx/lynx-core'; // or '@sigx/lynx'
const res = await fetch(`${env.apiBaseUrl}/me`);It is typed by the global SigxAppEnv interface. The app extends it once from
its config with EnvOf<typeof config>; see the
@sigx/lynx-cli App environment docs.
Everything in env ships inside the app, so never put secrets in it.
Logging
A tiny leveled + namespaced logger lives here so any package can log without taking a new dependency.
App code should import it from the umbrella — import { createLogger } from '@sigx/lynx' (which
re-exports the logging API); the @sigx/lynx-core import below is for module authors / packages that
already depend on core.
import { createLogger, setLogLevel, disableNamespace } from '@sigx/lynx-core'; // app code: from '@sigx/lynx'
const log = createLogger('checkout');
log.debug('cart opened', { items: 3 });
log.warn('coupon expired', code);
log.error('charge failed', err);- Levels:
trace<debug<info<warn<error(plussilent). Records at or above the current threshold are emitted; below are dropped. - Default level:
debugin development,warnin release builds — so verbose traces are dev-only with zero config. Override at runtime withsetLogLevel('info' | 'warn' | 'silent' | …). - Namespaces:
createLogger(ns)tags every record; silence one withdisableNamespace(ns)/ restore withenableNamespace(ns).log.enabled(level)lets you guard expensive log construction on hot paths. - Transports: records flow to pluggable sinks. The default
consoleTransportroutes by level toconsole.*, which@sigx/lynx-dev-clientstreams to thesigx devterminal in development (no extra wiring). Add your own withaddTransport(record => …);clearTransports()replaces the default. Production error capture and remote provider sinks will live in the opt-in@sigx/lynx-observabilitypackage, which registers transports here.
import { addTransport, type LogRecord } from '@sigx/lynx-core';
addTransport((r: LogRecord) => myBackend.send(r)); // { level, namespace, msg, fields, ts }Platform checks & rendering
Platform gives RN-style platform checks, sourced from the Lynx SystemInfo
global. App code should import it from the umbrella — import { Platform } from '@sigx/lynx'.
import { Platform } from '@sigx/lynx'; // module authors: from '@sigx/lynx-core'
Platform.OS; // 'ios' | 'android' | 'web'
Platform.Version; // OS version string, e.g. '17.4'
Platform.pixelRatio; // device pixel ratio (also pixelWidth / pixelHeight)
Platform.isPad; // best-effort iPad detection
const gap = Platform.select({ ios: 8, android: 12, web: 16, default: 0 });Platform.select(spec) precedence is exact OS key → native (ios/android) →
default. Provide default and the return type is T; omit it and it's T | undefined.
Two tiers. Platform.OS is a runtime convenience — both platform branches
ship in every bundle, like React Native. For tree-shakeable platform code,
branch on the build-time defines @sigx/lynx-plugin injects, or use
platform file extensions:
// __WEB__ / __NATIVE__ fold to literals per rspeedy environment, so the dead
// branch is dropped from the other bundle. (Types via `@sigx/lynx/client`.)
if (__WEB__) { /* web-only code, absent from the native bundle */ }
// __MAIN_THREAD__ / __BACKGROUND__ fold to literals per bundle LAYER (Lepus
// main thread vs background JS). Inside a 'main thread' worklet body the
// registered MT form keeps only its __MAIN_THREAD__ branch; everywhere else
// only __BACKGROUND__ branches survive. App/workspace-src code only —
// published dists must use a runtime check instead.
if (__BACKGROUND__) { /* absent from the main-thread bundle */ }- File extensions:
Foo.web.tsxresolves on the web bundle,Foo.lynx.tsx/Foo.native.tsxon the native bundle, each ahead ofFoo.tsx. Only web↔native swaps — iOS and Android share one native bundle, so usePlatform.OS/Platform.selectat runtime to split those.
Secure random bytes
getRandomBytes(length) returns length (1–1024) cryptographically secure
random bytes as a Uint8Array, synchronously. Use it for nonces, PKCE
verifiers, OAuth state, or IDs. @sigx/lynx-webauth's OAuth helpers use it.
import { getRandomBytes } from '@sigx/lynx-core';
const nonce = getRandomBytes(16);It uses crypto.getRandomValues where the runtime has it (web builds, engines
with Web Crypto). Otherwise it uses the platform CSPRNG through core's own
SigxCore module: SecRandomCopyBytes on iOS, SecureRandom on Android. That
covers the Lynx background thread on iOS, which has no Web Crypto. SigxCore is
linked in every build, so there is nothing to register.
There is no insecure fallback. It throws a SigxError with:
| code | Means |
| --- | --- |
| 'invalid_argument' | length isn't an integer from 1 to 1024. |
| 'no_random_source' | Neither Web Crypto nor the native CSPRNG is available, or the native call failed. |
Device info
DeviceInfo is an async, native-backed snapshot (manufacturer, model, brand,
OS/app version, screen metrics) — complementing the synchronous Platform
surface. Served by core's own SigxCore native module.
getInfo() resolves a platform-discriminated DeviceInfoResult: a common
core present on every platform, plus a platform discriminant that narrows to
per-platform extras. Switch on info.platform to read them type-safely.
import { DeviceInfo } from '@sigx/lynx'; // module authors: from '@sigx/lynx-core'
if (DeviceInfo.isAvailable()) {
const info = await DeviceInfo.getInfo();
console.log(info.model, info.systemVersion);
if (info.platform === 'ios') {
console.log(info.bundleId, info.modelName); // iOS-only extras
} else {
console.log(info.appPackage, info.sdkVersion); // Android-only extras
}
}Common fields (both platforms, identical semantics): platform,
manufacturer, model, brand, systemName, systemVersion, appVersion,
deviceId, screenWidth, screenHeight, screenScale. Screen dimensions are
density-independent points (dp/pt) on both platforms and screenScale is the
dp→physical-px multiplier — physical pixels ≈ Math.round(screenWidth * screenScale)
(approximate: dp is reported as an integer, so exact pixel recovery isn't guaranteed).
iOS extras: modelName (hardware id, e.g. "iPhone16,2"), appBuildNumber,
bundleId. Android extras: sdkVersion, appPackage.
Field caveats:
modelis a friendly name on Android (Build.MODEL) but the generic"iPhone"/"iPad"on iOS (the hardware id is the iOS-onlymodelName).deviceIdis a per-vendor stable UUID on iOS (identifierForVendor) butBuild.ID— a build identifier, not a stable device id — on Android.
Screen metrics & orientation
Platform.pixelWidth / pixelHeight describe the device and are read once
at module load. For anything that feeds layout, use useScreen() — it follows
rotation, split-screen and foldable unfold, fed by core's native
ScreenMetricsPublisher.
import { useScreen, useOrientation, useScreenMT } from '@sigx/lynx';
const screen = useScreen(); // Computed<ScreenMetrics>, BG-reactive
// screen.value → { width, height, scale, orientation, isLandscape }
// width/height are logical (dp on Android, pt on iOS)
// orientation is 'portrait' | 'portrait-upside-down'
// | 'landscape-left' | 'landscape-right'
const orientation = useOrientation(); // Computed<'portrait' | 'landscape'>
const onTap = () => {
'main thread';
const { height } = useScreenMT(); // sync read, worklet-safe
};useScreenMT() is itself a 'main thread' function, so a worklet can capture
and call it. Call it from 'main thread' code only: in an app bundle its
background-thread binding is a worklet reference, not a function. The same
holds for useWidthClassMT() / useHeightClassMT().
Calling helpers from a worklet. A worklet reaches the module-level functions it names through its capture, which crosses to the main thread as JSON. A
'main thread'function crosses as a callable reference; a plain function is dropped, and calling it throwsTypeError: not a functionon the main thread (#1201). Mark any helper a worklet calls with'main thread'.
readGlobalScreen() is the raw synchronous read (ScreenMetrics | null). It
works on both threads — lynx.SystemInfo is empty on the background thread,
__globalProps is not — which makes it the BG-safe screen-size accessor.
Window size classes
For layout decisions, prefer these over useScreen(). They derive from the
same publisher, but they are bucketed or boolean — and a computed() only
notifies when its value actually changes — so a threshold read fires exactly
once as the threshold is crossed, where useScreen() fires on every pixel of an
iPad Split View drag.
import {
Breakpoint, useWidthAtLeast, useWidthClass, useHeightClass, useWidthClassMT,
} from '@sigx/lynx';
const isExpanded = useWidthAtLeast(Breakpoint.WIDTH_EXPANDED); // Computed<boolean>
return () => (isExpanded.value ? <Sidebar /> : <BottomBar />);
const w = useWidthClass(); // Computed<'compact'|'medium'|'expanded'|'large'|'xlarge'>
const h = useHeightClass(); // Computed<'compact'|'medium'|'expanded'>| Constant | dp | Constant | dp |
|---|---|---|---|
| WIDTH_MEDIUM | 600 | WIDTH_LARGE | 1200 |
| WIDTH_EXPANDED | 840 | WIDTH_XLARGE | 1600 |
| HEIGHT_MEDIUM | 480 | HEIGHT_EXPANDED | 900 |
Material's thresholds — the one widely-used set expressed in
density-independent units rather than desktop CSS pixels, which is the right
currency here since Lynx lengths are already dp/pt. An iPad Air 13 is
expanded in portrait (1024) and large in landscape (1366); an iPhone 15 Pro
is compact (393).
Branch on height too. An iPhone 15 Pro in landscape is 852×393 — its
width reads expanded, the same bucket as an iPad in portrait, while its height
is compact. A width-only rule is how a vertical nav rail ends up on a 393dp-tall
screen.
Predicates take any dp, not just a Breakpoint — useWidthAtLeast(720)
compares the real width, it is not snapped to a bucket. They are monotone
"at least" reads on purpose: adding a bucket later can never silently change
what an existing call site means. Pass a constant, though — results are
memoized per threshold, so a value derived per render would grow the cache.
useWidthClassMT() / useHeightClassMT() are the worklet-safe reads. There is
no useWidthAtLeastMT(dp) — a worklet already has the number, so write
useScreenMT().width >= Breakpoint.WIDTH_EXPANDED.
widthClassOf(dp) / heightClassOf(dp) are the pure bucket functions behind
all of it, for callers that already hold a measurement.
Platform.isPad is not a layout signal: it is a load-time property of the
device, so it stays true for an iPad in a narrow Split View pane and false
for every Android tablet. Use it for device-scoped decisions only.
Runtime orientation lock
import { Orientation, useOrientationLock } from '@sigx/lynx';
await Orientation.lock('landscape'); // 'portrait' | 'landscape' | 'landscape-left' | …
await Orientation.unlock(); // back to the configured set
useOrientationLock('landscape'); // locks on mount, restores on unmount'default' means "no runtime lock" — lock('default') is an alias for
unlock() on every platform. useOrientationLock keeps a stack of holders,
because @sigx/lynx-navigation leaves a covered screen mounted under a card,
modal or sheet: popping the covering screen restores the covered one's lock
rather than falling through to the build-time default, and the runtime lock is
only released once the last holder unmounts.
The build-time config is the ceiling. signalx.config.ts's orientation
(default 'portrait') is written into android:screenOrientation and iOS's
UISupportedInterfaceOrientations; the OS won't rotate outside it. Requesting
an orientation the app didn't declare rejects with a message naming the
config change — set orientation: 'default' or 'all' and re-run
sigx prebuild. The rejection is a SigxError with code: 'native_error', so
branch on the code rather than the message.
On iOS 15 a lock takes effect at the next physical rotation rather than snapping
immediately: forcing rotation needs requestGeometryUpdate (iOS 16+). Raise
ios.deploymentTarget to '16.0' for the immediate flip.
Web. useScreen() / useOrientation() / readGlobalScreen() work — the
@sigx/lynx-web-host page bridge publishes the same channels from resize and
screen.orientation. Orientation.lock() is degraded: the Screen Orientation
API only permits locking while the document is fullscreen and several browsers
(all of iOS, desktop Safari) don't implement it at all, so lock() rejects with
an explanatory message there. Feature-detect with Orientation.isAvailable().
Back interceptors
import { addBackInterceptor, dispatchBackInterceptors, hasBackInterceptors } from '@sigx/lynx-core';addBackInterceptor(fn: () => boolean): () => void— register a handler for the Android back press. Interceptors run newest first; the first to returntrueconsumes the press. Returns the unsubscribe (C7), idempotent. Apps import it from@sigx/lynx.dispatchBackInterceptors(): boolean— offer a press to the interceptors;truemeans one consumed it and the caller must not navigate. A throwing interceptor is logged and skipped. Called by@sigx/lynx-navigation's hardware-back wiring before it pops, and by the iOS edge-swipe strips (the navigator's and@sigx/lynx-zero's overlay outlet's).hasBackInterceptors(): boolean— whether any interceptor is registered. A reactive read: an effect or render that calls it re-runs when the answer flips.<Stack>uses it to turn its iOS edge swipe into a back press while an interceptor is registered (#1312).
Pure JS, no native side: the contract lives in core so overlays
(@sigx/lynx-zero's dismiss stack) can close on back without depending on
navigation (#1290). iOS has
no back button; its back is the edge swipe, which dispatches to the same
interceptors.
Permissions helpers
For modules that need runtime permissions (camera, location, notifications, …) the package re-exports the shared PermissionStatus / PermissionResponse types used by @sigx/lynx-permissions.
Shared native helpers
Besides the JS bridge, the package ships a small shared native runtime that the autolinker copies into the generated project whenever any native module is installed (the package is discovered transitively — modules depend on @sigx/lynx-core, so apps never declare it for this):
Android —
com.sigx.core.SigxActivityHolder: weak reference to the current foreground Activity, fed by the auto-linkedSigxActivityHooklifecycle hook. Modules that present platform UI (BiometricPrompt,DatePickerDialog, permission dialogs, …) readcurrent()orcurrentFragmentActivity()at call time instead of each shipping their own holder.iOS —
SigxPresentation.topPresenter(): top-mostUIViewControlleron the active scene's key window (multi-scene safe, walks the presented-modal chain). Used by the picker modules to present sheets.FontScalePublisher(both platforms): follows the OS text-size setting (iOS Dynamic Type viaUIFontMetrics, AndroidConfiguration.fontScale), clamped by the app'sfontScalepolicy fromsignalx.config.ts. Seedslynx.__globalProps.fontScale = { scale, os }before first paint and pushes runtime changes into the engine viaLynxView.updateFontScale()— the engine relayouts text in place and emitsonFontScaleChangedto JS.The JS reads live here too (re-exported by
@sigx/lynxand@sigx/lynx-appearance):useFontScale()— reactiveComputed<number>of the effective scale (1= default; no provider needed);useFontScaleMT()— sync read for'main thread'worklet bodies;readGlobalFontScale()— sync{ scale, os }ornullwhen unwired. The engine scales ordinary text automatically — use these to adapt around larger text (layout swaps, custom-drawn text like@sigx/lynx-markdown's editor, icon sizing).ScreenMetricsPublisher(both platforms): publishes the live viewport size + interface orientation tolynx.__globalProps.screenand thescreenChangedglobal event — whatuseScreen()reads. It also keeps the engine in sync:LynxView.updateScreenMetrics()re-basesrpx, and on iOS it re-pinspreferredLayoutWidth/Heightand callsupdateViewport(…needLayout:), without which the exact-mode layout box would stay at its launch size forever. The managed Android manifest declaresconfigChanges="…|orientation|screenSize|…"so a rotation is handled in place instead of recreating the Activity and reloading the bundle.SigxOrientation(both platforms): the runtime lock behindOrientation.lock(). Android setsActivity.requestedOrientationand restores the manifest-declared value on unlock; iOS holds the mask the host AppDelegate'ssupportedInterfaceOrientationsForreturns (its default is stamped fromsignalx.config.tsbysigx prebuild, because implementing that delegate method overridesInfo.plist).
The package also registers core's own native module, SigxCore, which backs DeviceInfo (getDeviceInfo), AppState (getAppState) and the orientation lock (lockOrientation / unlockOrientation). Those four are the whole surface — every method the module implements is called from JS and declared in signalx-module.json (CONVENTIONS.md C12).
Module authors: don't add a per-package Activity holder or top-presenter helper — use these.
Linking
Native modules are wired by @sigx/lynx-cli's autolinker. Install the package (pnpm add @sigx/lynx-foo), run sigx prebuild, and the generated registry takes care of the rest — the signalx-module.json manifest each module ships is what makes auto-discovery work.
Web
Mixed by surface, since core is half host-agnostic JS and half native bridge:
- Unchanged on web.
Platform/select()(Platform.OS === 'web'), the logger and its transports,SigxError/unwrapNative,base64ToArrayBuffer/arrayBufferToBase64,getRandomBytes(the browser'scrypto.getRandomValues), andsubscribeNative— upstream web-core does inject aGlobalEventEmitterinto the worker, so native-event subscriptions genuinely deliver on web (that is how the appearance and screen channels below arrive). Its silent-no-op path is for hosts where no emitter is reachable at all (SSR, tests), which is why packages can subscribe unconditionally. - Served by the page bridge.
useScreen()/useOrientation()/readGlobalScreen()work —@sigx/lynx-web-hostpublishes the same__globalProps.screen+screenChangedchannels fromresizeandscreen.orientation. The size-class reads (useWidthClass(),useWidthAtLeast(), …) are pure derivations of that signal, so they work on web too.webHostCall()/isWebHostAvailable()are the worker→page RPC used by the.web.tsshims that need an API only the page has: clipboard, linking, share, file picker, image picker, haptics, location, notifications. Shims for APIs the worker already owns don't go through it — storage uses IndexedDB, network readsnavigator.onLine, websocket uses the worker's ownWebSocket, and observability's memory read is local. - Degraded.
Orientation.lock()resolves toorientation.web.ts, backed by the Screen Orientation API: locking only works on a fullscreen document and several browsers (all of iOS, desktop Safari) don't implement it at all, so it rejects with an explanatory message there. Feature-detect withOrientation.isAvailable(). - Unsupported. The
SigxCorenative module isn't registered on web, soDeviceInfoandAppStatehave no source —DeviceInfo.isAvailable()/AppState.availablearefalse,AppState.currentstays'active'and its subscriptions never fire (the Page Visibility API is what a future shim would use). Nothing publishes__globalProps.fontScaleeither, soreadGlobalFontScale()returnsnullanduseFontScale()stays at1.
License
MIT
