@moku-labs/system
v0.3.1
Published
Isomorphic system API for Moku — store, notify, clipboard, tray, deep-link, lifecycle, back, haptics, and keep-awake capabilities via an env-style provider pattern (Tauri provider when the shell is detected, web fallback otherwise). Same island code runs
Readme
@moku-labs/system
One system API, two runtimes — the same island code talks to the OS on native and degrades honestly on the web.
Isomorphic system capabilities for Moku apps — store, tray, notify, clipboard, deep-link, lifecycle, back, haptics, and keepAwake — behind an env-style provider seam: a Tauri provider when the native shell is detected, a web provider otherwise. Your islands never branch on the runtime; a capability that cannot work where it runs says so as typed data (SystemResult), never as a thrown surprise. It is not a UI framework and not a Tauri wrapper — it is the seam between your app and whatever shell it happens to be running in.
Install · Quick start · Plugins · The SystemResult contract · Native permissions · Usage · Development
Why @moku-labs/system
- One codebase, web and native. Every capability selects its provider once at startup (Tauri shell detected → native, otherwise → web). Island code calls the same
app.store.get(...)everywhere — no second code path to keep in sync. - Degraded capability is data, never a throw. Every method returns
SystemResult<T>—{ ok: true, value }or{ ok: false, reason }with a typed reason (unsupported/denied/unavailable/error). Tray on the web isn't a crash; it's an answer. - Zero-leak bundles. Plugin instances live at subpath exports (
@moku-labs/system/store, …); the root exports onlycreateApp,createPlugin,ok,err, and types. A store-only consumer carries zero bytes of tray/notify/clipboard/deep-link/lifecycle/back/haptics/keep-awake code. - Not a Tauri wrapper.
@tauri-apps/*packages are optional peer dependencies, dynamically imported only inside the Tauri providers. Pure-web consumers never install them — and never pay for them. - Every exposed method genuinely works, or has a documented typed absence, on both providers. No method that silently no-ops on one side.
Install
bun add @moku-labs/systemBuilding the native shell too? Add the Tauri plugins the capabilities you compose need:
bun add @tauri-apps/api @tauri-apps/plugin-store @tauri-apps/plugin-notification \
@tauri-apps/plugin-clipboard-manager @tauri-apps/plugin-deep-link \
@tauri-apps/plugin-haptics[!NOTE] Status:
0.x— early. All@tauri-apps/*packages are optional peer dependencies (@tauri-apps/api^2.12.0,@tauri-apps/plugin-haptics^2.4.0) — they are only loaded inside the Tauri providers, via dynamic import, when the native shell is actually detected. Pure-web consumers skip them entirely.
Quick start
import { createApp } from "@moku-labs/system";
import { storePlugin } from "@moku-labs/system/store";
const system = createApp({
plugins: [storePlugin],
pluginConfigs: { store: { name: "my-app" } }
});
await system.start();
const result = await system.store.get<number>("count");
if (result.ok) {
console.log(result.value ?? 0, `(from the ${result.provider} provider)`);
} else if (result.reason === "unsupported") {
// hide the feature — this environment will never support it
} else {
console.warn(result.reason, result.message);
}
await system.stop();[!IMPORTANT] Plugin instances are imported from subpaths —
@moku-labs/system/store,/tray,/notify,/clipboard,/deep-link,/lifecycle,/back,/haptics,/keep-awake. The root entry exportscreateApp,createPlugin,ok,err, and types only. Why: zero-leak bundles — a root barrel re-exporting instances leaked ~2 KB gzipped of unimported capability code into every consumer (measured); with subpaths, a store-only app carries none of the other capabilities.
How it works
flowchart LR
C["Your island code<br/>app.store.get("count")"] --> A["createApp<br/>(Layer 3 composition)"]
A --> P["capability plugins<br/>store · tray · notify · clipboard · deepLink<br/>lifecycle · back · haptics · keepAwake"]
P --> R["runtime seam<br/>ctx.runtime — detected once"]
R --> W["web provider<br/>(fallback)"]
R --> T["Tauri provider<br/>(native shell)"]
W --> S["SystemResult<T><br/>typed outcome"]
T --> S
classDef u fill:#0b7285,stroke:#08525f,color:#fff;
classDef m fill:#1864ab,stroke:#0d3d6e,color:#fff;
class C,S u
class A,P,R,W,T mThe runtime core plugin (registered automatically, alongside logPlugin + envPlugin from @moku-labs/common) detects the shell once — kind: "tauri" when the Tauri 2 marker is present on globalThis, "web" otherwise — and injects ctx.runtime ({ kind, platform }) on every plugin's context. Each capability plugin resolves its provider from that detection at app.start(), fire-and-forget: resolution failures fold into the result of the next call, never into a startup throw. Detection happens once, inside the framework. Islands never branch on the runtime.
The SystemResult contract
Every capability method returns SystemResult<T> — a discriminated union you narrow with result.ok:
type SystemOk<T> = { ok: true; value: T; provider: "tauri" | "web" };
type SystemErr = { ok: false; provider: "tauri" | "web"; reason: SystemErrorReason; message?: string };
type SystemResult<T> = SystemOk<T> | SystemErr;| reason | Meaning | What to do |
|---|---|---|
| unsupported | The capability does not exist here — permanently (tray on web, tray on Tauri iOS/Android, clipboard in an insecure context). | Hide the feature. |
| denied | The user or platform unambiguously refused permission (notification permission not granted, clipboard NotAllowedError). | Explain, or call requestPermission() deliberately. |
| unavailable | The environment can't deliver right now — provider failed to resolve, app not started ("app not started — call app.start() first"), app already stopped ("app stopped"), Safari private-mode storage probe failed, haptics on the web before the first user gesture. | Retry later or degrade gracefully. |
| error | The provider threw during the operation; the raw message is preserved in message. | Log it (ctx.log already did) and recover. |
After app.stop() a provider whose teardown released a native resource (store, tray, deepLink, plus back.exit() and keepAwake.set(true)) answers unavailable / "app stopped" rather than touching a freed handle — an island that outlives the app gets a typed answer, not a crash. app.stop() itself waits at most 5 seconds for a provider still resolving, warns, and completes. A missing optional peer is named: @tauri-apps/plugin-store is not installed. Add it to the app, or list "store" in @moku-labs/native config.system.
Environmental failure is data; programmer errors still throw normally (e.g. an empty store.name fails fast at createApp with a TypeError). Thrown provider errors are never mapped to denied — Tauri ACL throws are ambiguous, so denied is reserved for unambiguous returned permission signals.
Plugins
Compose only what you need — each plugin mounts its API at app.<name>:
| Plugin | Import from | Config (pluginConfigs key) | Key API | Events |
|---|---|---|---|---|
| storePlugin | @moku-labs/system/store | store: { name } (default "moku-system") | get / set / delete / keys / clear — JSON-safe key-value persistence (Tauri store file with awaited save(); IndexedDB via idb-keyval on web) | — |
| trayPlugin | @moku-labs/system/tray | tray: { id, icon? } (default "moku-system"; no icon = the app's default window icon) | setMenu / setTooltip / setIcon / destroy — desktop tray icon, created lazily on first mutating call | — |
| notifyPlugin | @moku-labs/system/notify | — (no config) | show / requestPermission / isPermissionGranted — explicit permission flow; show() never auto-prompts | — |
| clipboardPlugin | @moku-labs/system/clipboard | — (no config) | readText / writeText — text only; feature-probed, NotAllowedError → "denied" | — |
| deepLinkPlugin | @moku-labs/system/deep-link | deepLink: { schemes } (default [] = all) | getCurrent / onOpen — launch URL + runtime deliveries; only the one-time launch replay is deduped | deepLink:open |
| lifecyclePlugin | @moku-labs/system/lifecycle | — (no config) | onPause / onResume → remover — tauri://suspended / tauri://resumed + visibilitychange, deduped to one call per trip | — |
| backPlugin | @moku-labs/system/back | — (no config) | onPress(fn → boolean) → remover, exit() — Android in Tauri, unsupported elsewhere; native listener held only while a handler exists | — |
| hapticsPlugin | @moku-labs/system/haptics | — (no config) | impact("light" \| "medium" \| "heavy") / notify("success" \| "warning" \| "error") / selection() — Tauri iOS/Android plugin, navigator.vibrate on Android web, unsupported on iOS web | — |
| keepAwakePlugin | @moku-labs/system/keep-awake | — (no config) | set(on) — Screen Wake Lock in browser and webview, re-acquired when visible; NotAllowedError → "denied", hidden page → "unavailable" | — |
| runtime (core — auto-registered) | — | runtime: { forceKind, forcePlatform } (default null = auto-detect) | ctx.runtime.kind / ctx.runtime.platform — the single override point for the whole seam | — |
[!NOTE]
trayis desktop-only by nature: the web provider and the Tauri mobile (iOS/Android) branch both answer every method witherr("unsupported")— same contract, no special-casing.
[!TIP] Deep links on the web. There is no push channel, and the page's own address is never treated as a deep link. The web provider reads an explicit parameter instead:
?deeplink=or#deeplink=, percent-encoded —https://app.example/?deeplink=myapp%3A%2F%2Fopen%3Fid%3D1. Anything else resolvesok(null).
Native permissions
Every capability that reaches the OS needs a Tauri ACL permission in the app's capability file and a Rust-side plugin. @moku-labs/native codegens exactly this — the capability file, the Cargo dependencies and the plugin registrations — from whichever system plugins your app composes. The table is here so you can audit what a native build will ask for.
| Capability | ACL permissions | Rust side |
|---|---|---|
| store | store:default | tauri-plugin-store — tauri_plugin_store::Builder::default().build() |
| notify | notification:default | tauri-plugin-notification — init() |
| clipboard | clipboard-manager:allow-read-text, clipboard-manager:allow-write-text | tauri-plugin-clipboard-manager — init() |
| deepLink | deep-link:default, core:event:default | tauri-plugin-deep-link — init(), schemes declared in the Tauri config |
| tray | core:tray:default, core:menu:default, core:image:default, core:resources:default | no plugin crate — Cargo features tray-icon + image-png. Desktop only |
| lifecycle | none beyond core:default (core:event:default) | none |
| back | core:app:allow-exit (listener permissions are in core:default) | none — core API. Android only |
| haptics | haptics:allow-impact-feedback, haptics:allow-notification-feedback, haptics:allow-selection-feedback, haptics:allow-vibrate (no default set exists) | tauri-plugin-haptics — init(). iOS + Android |
| keepAwake | none | none — navigator.wakeLock in the webview |
Two traps the table hides: clipboard-manager:default grants nothing, so the two allow-* permissions are the ones that matter; the core:* entries (tray's four, and core:event:default) all ship inside core:default. Haptics has no default permission set, so all four allow-* permissions are listed. Deep links on Windows and Linux also need single-instance forwarding, which the packager wires.
The two sides name the plugins differently — @moku-labs/native keys its config.system by the Tauri plugin name:
| This framework | @moku-labs/native config.system |
|---|---|
| store | store |
| notify | notification |
| clipboard | clipboard-manager |
| tray | tray |
| deepLink | deep-link |
| back | back |
| haptics | haptics |
| lifecycle | — (not listed, no native plugin) |
| keepAwake | — (not listed, no native plugin) |
Usage
Composing multiple capabilities
import { createApp } from "@moku-labs/system";
import { deepLinkPlugin } from "@moku-labs/system/deep-link";
import { notifyPlugin } from "@moku-labs/system/notify";
import { storePlugin } from "@moku-labs/system/store";
const system = createApp({
plugins: [storePlugin, notifyPlugin, deepLinkPlugin],
pluginConfigs: {
store: { name: "my-app" },
deepLink: { schemes: ["myapp"] }
// notify and clipboard take no config — nothing to put here
}
});
await system.start();
// Explicit permission flow — show() never auto-prompts.
const granted = await system.notify.isPermissionGranted();
if (granted.ok && !granted.value) {
await system.notify.requestPermission();
}
await system.notify.show({ title: "Done", body: "Export finished." });
// Deep links: launch URL + runtime deliveries.
const launch = await system.deepLink.getCurrent();
const unsubscribe = system.deepLink.onOpen(({ url }) => route(url));Bundling for the web
Each capability loads its Tauri provider through a lazy import("./tauri"), so @tauri-apps/* ends up in separate chunks that a web build never executes. Your bundler still has to resolve those specifiers. Either install the optional peers (they stay in the unused chunks), or mark them external:
// Bun
await Bun.build({ entrypoints: ["src/main.ts"], outdir: "dist", external: ["@tauri-apps/*"] });// Vite / Rollup
export default { build: { rollupOptions: { external: /^@tauri-apps\// } } };Forcing the runtime (tests, storybooks)
The runtime core plugin detects the shell from the environment — "tauri" when globalThis.isTauri === true or the __TAURI_INTERNALS__ marker is present, "web" otherwise, and the platform from the user-agent. Consumer tests make provider decisions deterministic by stubbing that environment before the app starts:
// Force "tauri" detection: define the shell marker before start() — and pair it with
// mocks of the @tauri-apps/* modules your capabilities load (see each plugin README).
vi.stubGlobal("__TAURI_INTERNALS__", {});
// Force "web" detection: simply run without the marker (the default in vitest/Node).
const app = createApp({
plugins: [storePlugin],
pluginConfigs: { store: { name: "test-db" } }
});Detection can also be overridden directly: pluginConfigs: { runtime: { forceKind, forcePlatform } } is applied — consumer config wins the core-plugin cascade. One catch: runtime is a core plugin, so it is not one of createApp's typed pluginConfigs keys (those come from the plugins you compose). Hoist the object instead of inlining it, and TypeScript accepts the extra key:
const pluginConfigs = {
store: { name: "test-db" },
runtime: { forceKind: "web", forcePlatform: "windows" }
} as const;
const app = createApp({ plugins: [storePlugin], pluginConfigs });[!TIP] Extending the framework.
createPlugindefines a consumer plugin bound to this framework's types — itsctxcarriesctx.runtime,ctx.log, andctx.env:import { createApp, createPlugin } from "@moku-labs/system"; const myPlugin = createPlugin("my", { api: ctx => ({ ping: () => ctx.runtime.kind }) }); const app = createApp({ plugins: [myPlugin] });
Development
bun run build # Build with tsdown (root + 9 subpath entries)
bun run test # All tests (vitest)
bun run test:unit # Unit tests only
bun run test:integration # Integration tests only
bun run test:coverage # Coverage (90% threshold)
bun run lint # Biome check + ESLint
bun run lint:fix # Auto-fix lint issues
bun run format # Format with Biome
bun run validate # publint + arethetypeswrongBuilt test-first. Plugin tests are colocated (src/plugins/<name>/__tests__/unit/ and __tests__/integration/); root tests/ holds framework-level integration only. Providers are exercised through test doubles — the web store provider runs against fake-indexeddb.
Requirements
- Node
>= 24and Bun>= 1.3.14— usebunexclusively (never npm/yarn/pnpm). - TypeScript in strict mode, with
exactOptionalPropertyTypesandnoUncheckedIndexedAccess. @moku-labs/core— the micro-kernel this framework is built on (bundled dependency).@tauri-apps/*— optional peers, only in the native shell (see Install).
Docs
- Per-plugin READMEs: store · tray · notify · clipboard · deep-link · lifecycle · back · haptics · keep-awake · runtime
- Moku Core specification — the three-layer model, factory chain, lifecycle, events.
llms.txt/llms-full.txt— LLM-oriented API reference.
